# Lumail.io
> The AI-native email platform for newsletters, automations, and transactional email.

Full markdown concatenation of every published docs page.

# Documentation
Source: https://lumail.io/docs.md

Lumail is email marketing for creators and small teams. Start with how the product works. Use [Guides](/docs/guides) when you want a path, or [API Reference](/docs/api-reference/v2) when you want endpoints.

<DocCardWrapper>

<DocSection title="Product">
  <DocCardGrid>
    <DocCard
      href="/docs/features"
      icon="Layers"
      title="Features"
      description="Variables, tags, surveys, language, and sending queue."
    />
    <DocCard
      href="/docs/workflows"
      icon="Workflow"
      title="Workflows"
      description="Automations: triggers, emails, waits, branches, and exits."
    />
    <DocCard
      href="/docs/deliverability"
      icon="Shield"
      title="Deliverability"
      description="Domains, bounces, DMARC, and the alerts that fire when sending slips."
    />
    <DocCard
      href="/docs/integrations"
      icon="Plug"
      title="Integrations"
      description="WordPress, ClickFunnels, Systeme.io, and SMTP tools."
    />
  </DocCardGrid>
</DocSection>

</DocCardWrapper>

---

# SMTP
Source: https://lumail.io/docs/smtp.md

Use Lumail SMTP when an application can only send through SMTP and cannot call `POST /api/v1/emails` directly.

The endpoint is designed for low-volume transactional email: authentication codes, magic links, password resets, account invitations, receipts, and operational notifications. SMTP messages are accepted by the Lumail SMTP adapter, authenticated with a Lumail API token, then queued through the transactional priority lane.

## Connection Settings

| Setting  | Value                                  |
| -------- | -------------------------------------- |
| Host     | `smtp.lumail.io`                       |
| Port     | `587`                                  |
| Security | STARTTLS                               |
| Username | Your organization slug, or any label   |
| Password | A Lumail API token                     |
| From     | An address on a verified Lumail domain |

The password is not your Lumail account password. Create an API token from **Settings -> API Tokens** and store it in your application's secret manager.

## Setup Checklist

1. Verify the sending domain in Lumail.
2. Create a Lumail API token in the same organization.
3. Configure your app to use `smtp.lumail.io` on port `587` with STARTTLS.
4. Use a `From` address on the verified domain.
5. Send one test email before enabling the integration in production.

> **Test-mode recipients.** Fixture addresses such as `user@example.com` never send real email. See [Test-mode recipients](/docs/api-reference/test-mode).
>
> **Recipient rate guards.** The same real mailbox is capped at 5 sends / 10 minutes and 20 / 24 hours. See [Recipient rate guards](/docs/deliverability/recipient-rate-guards).

For the full setup flow, use the [SMTP transactional email tutorial](/docs/tutorials/smtp-transactional-emails).

## Supabase Auth

Supabase Auth custom SMTP can use these values:

```text
Host: smtp.lumail.io
Port: 587
Security: STARTTLS
Username: your-org-slug
Password: lum_your_api_token
Sender email: auth@your-verified-domain.com
```

If your application supports HTTP hooks, prefer [POST /api/v1/emails](/docs/api-reference/api-emails-send). The HTTP API returns structured errors and avoids SMTP/MIME parsing.

## Related Documentation

- [SMTP transactional email tutorial](/docs/tutorials/smtp-transactional-emails) - End-to-end setup for an organization
- [SMTP endpoint reference](/docs/api-reference/smtp) - Response codes, limits, and behavior
- [Create an API token](/docs/tutorials/create-api-token) - Generate the SMTP password
- [Email domains](/docs/domains/email-domains) - Verify the sender domain

---

# Survey Block Formats
Source: https://lumail.io/docs/survey.md

A survey block is a one-question poll inside the email. Recipients answer by
clicking. There is no form to load and no account to create. The editor preview
is the email: same stacking, same button size, same labels, same bordered card.

Open the slash menu (`/`) and pick **Survey**. Switch the style from the block
toolbar.

## Buttons

Full-width answers, stacked, left-aligned, all the same size. Use this for a
short list of choices.

<div className="not-typography my-6 rounded-xl bg-zinc-100 p-5 dark:bg-zinc-900">
  <img
    src="/docs/surveys/buttons.png"
    alt="Buttons survey with three equal-width stacked answers"
    className="m-0 w-full"
  />
</div>

## Yes / No

Two stacked answers. Same geometry as Buttons — Yes sits above No, both full
width. Switching to this style rewrites the first two answers as Yes / No and
keeps the rest saved if you switch back.

<div className="not-typography my-6 rounded-xl bg-zinc-100 p-5 dark:bg-zinc-900">
  <img
    src="/docs/surveys/yes-no.png"
    alt="Yes / No survey with two equal-width stacked answers"
    className="m-0 w-full"
  />
</div>

## Emoji

Same stacked buttons, with an emoji you pick printed in front of each label.
Only this style shows the emoji in the email.

<div className="not-typography my-6 rounded-xl bg-zinc-100 p-5 dark:bg-zinc-900">
  <img
    src="/docs/surveys/emoji.png"
    alt="Emoji survey with stacked answers prefixed by an emoji"
    className="m-0 w-full"
  />
</div>

## NPS

A fixed 0–10 scale, left-aligned with the question. The captions under 0 and 10
are yours; leave them blank to use the recipient's language.

<div className="not-typography my-6 rounded-xl bg-zinc-100 p-5 dark:bg-zinc-900">
  <img
    src="/docs/surveys/nps.png"
    alt="NPS survey with a 0 to 10 scale"
    className="m-0 w-full"
  />
</div>

## Block settings

Click the survey to open the toolbar.

| Setting           | What it does                                                                                      | Default       |
| ----------------- | ------------------------------------------------------------------------------------------------- | ------------- |
| **Style**         | `Buttons`, `Yes / No`, `NPS`, `Emoji`                                                             | Buttons       |
| **Comment**       | Lets the recipient add a free-text note (up to 1000 characters) after they vote                   | Off           |
| **Alignment**     | `left`, `center`, `right` — moves the question and the NPS scale. Stacked answers stay full width | left          |
| **Answer colors** | Button fill and label color                                                                       | black / white |
| **Show if**       | Hide the block unless the subscriber matches the tag conditions                                   | always shown  |

NPS only: two caption fields under the scale (`Not at all` / `Absolutely` in
the editor). Empty means "translate on the vote page". Max 60 characters each.
Cosmetic edits (colors, alignment, visibility) never mint a new survey version.

## Limits

| Thing           | Limit                                             |
| --------------- | ------------------------------------------------- |
| Answers         | 2–8 (NPS is fixed at 0–10)                        |
| Question        | 300 characters                                    |
| Answer label    | 120 characters                                    |
| NPS captions    | 60 characters each                                |
| Comment         | 1000 characters                                   |
| Merge fields    | Not allowed in the question, answers, or captions |
| Inside a Repeat | Not allowed                                       |

Answers land on the campaign's **Survey** tab, on each subscriber's timeline,
and in subscriber filters. A `subscriber.survey.answered` workflow trigger
fires on the first answer only.

Once a campaign is sent, its survey block is frozen. Duplicate the campaign to
ask a different question.

For filters, automations, the TipTap node, and the anti-bot vote path, see
[Surveys (reference)](/docs/features/surveys). Default NPS captions follow the
[organization language](/docs/features/language).

---

# GDPR in Lumail
Source: https://lumail.io/docs/legal/gdpr-in-lumail.md

_Last reviewed: 2026-09-24_

> **Contract and transfer requirements:** Organization owners and administrators can accept and download a DPA in **Settings → Configuration → Data Processing Agreement**. Version 2026-09-15.1 incorporates EU SCC Modules Two and Three, the UK ICO Addendum and Swiss adaptations. It does not promise EU-only processing or complete the Customer's transfer assessment.

This page describes the product and infrastructure as they operate today. It is intended to help customers perform their own assessment; it is not legal advice and is not a certification that a particular use of Lumail complies with GDPR or French ePrivacy rules.

## Roles and responsibility

For subscriber lists, campaigns, and transactional recipients, the Lumail customer normally determines the purpose and means of processing and is the data controller. Codelynx, LLC operates Lumail and processes that data to provide the Service.

Codelynx is a separate controller for account, billing, security, support, and its own website analytics data. Customers remain responsible for their notices, legal bases, consent records, email content, recipient selection, and responses to data-subject requests.

## Current data map

| Data or operation                                                         | Primary system   | Current location                                                                                                                  |
| ------------------------------------------------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| Subscriber profiles, subscription state, campaigns, and core email events | Neon PostgreSQL  | AWS eu-central-1, Frankfurt, Germany                                                                                              |
| Application and Redis runtime                                             | netcup           | Nuremberg, Germany                                                                                                                |
| Email delivery and bounce or complaint feedback                           | Amazon SES       | ap-southeast-2 (Sydney, Australia), eu-central-1 (Frankfurt, Germany) or us-west-1 (N. California, USA), depending on the region selected for each sending domain |
| Sent-email content archives                                               | Cloudflare R2    | APAC placement; no EU-only jurisdiction guarantee                                                                                 |
| Transactional content and background-job orchestration                    | Hatchet          | Self-hosted in Nuremberg, Germany                                                                                                 |
| Queued message orchestration                                              | Upstash QStash   | EU service endpoint                                                                                                               |
| Product analytics and application diagnostics                             | PostHog Cloud EU | Frankfurt, Germany                                                                                                                |
| DNS, CDN, and security traffic                                            | Cloudflare       | Global edge network                                                                                                               |
| Limited operational delivery-failure alerts                               | Discord          | Global service; alerts may include a subscriber email address                                                                     |

Opens, clicks, bounces, complaints, and unsubscribe events are stored in the same Frankfurt PostgreSQL region as subscriber records. This does not mean all related data stays there: SES, R2, Cloudflare, QStash, and operational alerts can process related data elsewhere. Hatchet job payloads are processed on the self-hosted German worker.

See the [Privacy Policy](/legal/privacy) for the complete public disclosure.

## International transfers

Codelynx does not currently claim certification under the EU-U.S. Data Privacy Framework. DPA version 2026-09-15.1 incorporates the European Commission's 2021 Standard Contractual Clauses for controller-to-processor and processor-to-subprocessor transfers, the UK ICO Addendum and Swiss adaptations.

Those clauses create a Customer-to-Codelynx transfer instrument. They do not replace the required assessment of the destination country's laws, supplementary measures where needed, or the upstream safeguards for each onward transfer. Infrastructure-provider DPAs and SCCs cover only the parties and transfers identified in those agreements.

See [Data Processing Agreement](/docs/legal/dpa) for the current contractual status.

## GDPR setup checklist

Before collecting subscribers, an EU customer should:

1. **Complete the required agreements.** Review and accept the current organization DPA. It supplies the applicable EU, UK and Swiss transfer terms between you and Codelynx, but you must still document the transfer assessment and any supplementary measures before starting the affected processing.
2. **Publish your own privacy notice.** Identify your purposes, legal bases, recipients, transfers, retention periods, and how subscribers can exercise their rights.
3. **Enable native double opt-in.** In **Settings → Configuration → Double Opt-In**, enable the setting before collecting subscribers when confirmation is appropriate for your legal basis and market. Step-by-step: [Enable Double Opt-In](/docs/tutorials/enable-double-opt-in).
4. **Minimize data.** Send only fields needed for the stated purpose. Do not place special-category or children's data in Lumail without a separate assessment and written agreement.
5. **Choose tracking deliberately.** Open pixels and tracked links can require notice or consent. Marketing campaigns, native double-opt-in confirmation emails, and transactional API, SDK, MCP, and SMTP sends track opens and clicks. Set `tracking.open` and `tracking.links` to `false` when tracking is not necessary or not lawful. Lumail account OTP and magic-link emails are sent without tracking.
6. **Keep independent consent evidence.** Store the notice version, source, timestamp, purpose, and consent wording in your own system. Native Lumail confirmation stores `confirmedAt` and `confirmationIp`, but those fields are not a complete consent record by themselves.
7. **Test rights handling.** Verify export, unsubscribe, suppression, and **lawful erasure** (not the disabled DELETE API) before launch. Contact [help@codelynx.dev](mailto:help@codelynx.dev) for downstream provider deletion.

## Native and custom double opt-in

Lumail's recommended flow is the native double opt-in. See [Enable Double Opt-In](/docs/tutorials/enable-double-opt-in) for the Configuration screenshots and the confirmation email editor.

1. Enable **Double Opt-In** in organization configuration.
2. Add the subscriber through the Subscribers API or a Lumail capture flow.
3. Lumail creates the subscriber as `PENDING_CONFIRMATION` and sends the native confirmation email.
4. The Lumail confirmation link changes the status to `SUBSCRIBED` and records the confirmation time and IP address.

The transactional email API behaves differently:

- It always creates a subscriber row when the address does not already exist.
- By default that row is `TRANSACTIONAL`: delivery, bounce, and complaint tracking without marketing eligibility.
- If **Settings → Configuration → Add transactional recipients to the marketing list** is on, the new row is `SUBSCRIBED` and a subscribed event is recorded. Double opt-in is ignored on this path.
- Existing contacts are reused and never have their status changed. `TRANSACTIONAL` becomes `SUBSCRIBED` only on a real subscribe (capture, import, admin, or API upsert with resubscribe).
- Adding a custom tag does not change `PENDING_CONFIRMATION` or `TRANSACTIONAL` to `SUBSCRIBED`, populate `confirmedAt`, or prove consent in Lumail.

Therefore, do not use the transactional API plus a confirmation tag as Lumail's custom double-opt-in mechanism. Use the native flow, or keep the entire consent and marketing-eligibility state in your own system and do not treat Lumail's `SUBSCRIBED` status as proof of consent. Transactional sends track opens and clicks unless you disable them:

```json
{
  "transactional": true,
  "tracking": {
    "open": false,
    "links": false
  }
}
```

## Retention, deletion, and suppression

Lumail does not currently provide a contractually guaranteed retention period for every data category.

Ordinary subscriber `DELETE` is not a product operation: `DELETE /api/v1/subscribers/{id}` returns 405 and the `delete_subscriber` tool is disabled. Unsubscribe stops marketing mail and keeps history.

An authorized organization owner or admin can run **lawful erasure** from the subscriber detail page (confirm `ERASE`). That path:

- scrubs identifiable subscriber fields, email bodies, snapshots, clicks/opens, events, capture events, and related AI conversation mentions
- deletes R2 content objects whose keys are known
- writes a hashed tombstone so imports, API upserts, and transactional sends cannot recreate the address
- keeps hashed suppression / delivery-guard identities so the address is not emailed again

It does **not** automatically erase SES provider logs, PostHog events, historical import files, or database backups. Those exceptions are recorded on the erasure ledger. Contact [help@codelynx.dev](mailto:help@codelynx.dev) for downstream provider deletion. Do not promise a completion date for third-party stores without that follow-up.

The accepted DPA governs Codelynx's processing obligations; it does not imply an automated deletion deadline.

See [Customer compliance guide](/docs/legal/customer-compliance).

## Security and incident handling

Lumail uses TLS, access controls, separated environments, provider security controls, and abuse monitoring. These measures reduce risk but do not create an absolute security guarantee. Customers should limit API-token permissions, rotate credentials, use separate development and production tokens, and report suspected incidents to [help@codelynx.dev](mailto:help@codelynx.dev).

## Planned compliance work

The organization DPA can be accepted electronically and downloaded with its acceptance record. Maintaining the transfer assessment, upstream provider evidence and notification processes, and improving retention and downstream erasure controls remain distinct work. A signed DPA is not a certification of all product behavior or a completed transfer assessment.

---

# Data Processing Agreement (DPA)
Source: https://lumail.io/docs/legal/dpa.md

_Last reviewed: 2026-09-15_

Lumail provides an organization Data Processing Agreement (DPA) with **Codelynx, LLC**, 8 The Green STE B, Dover Delaware 19901, United States. It describes the parties' processing obligations, confidentiality, security, subprocessors, rights assistance, incidents, return and erasure, and audit arrangements.

## Review and accept

1. Open your organization's **Settings → Configuration → Data Processing Agreement**.
2. Read the agreement and confirm the organization's legal name and address.
3. Select whether the organization acts as a controller, a processor for its own client, or both for the transferred data.
4. Enter your full legal name, including your family name.
5. Check the express agreement and authority confirmation, then select **Sign agreement**.

Only an organization owner or administrator can sign on the organization's behalf. You must have authority to bind its legal entity. If your organization acts for a client, it must also have authority to appoint Codelynx as a further processor. A marketing opt-in or acceptance on behalf of your subscribers is not created by signing this contract.

## Download your agreement

After signing, use **Download signed PDF** to receive the contract with the recorded parties, signer and acceptance details. Keep a copy for your records.

The accepted text is saved with its version and acceptance time. Changing an organization profile or publishing a new template does not rewrite an earlier agreement. Downloading an unsigned document does not constitute acceptance.

## International transfers and compliance scope

The DPA is an agreement about processing obligations. It is **not**:

- An EU-only hosting or data-residency commitment.
- A Data Privacy Framework or security certification.
- A completed transfer impact assessment.

Version 2026-09-15.1 incorporates the European Commission's 2021 Standard Contractual Clauses by reference: Module Two for controller-to-processor transfers and Module Three for processor-to-subprocessor transfers. The Customer selects its applicable role or roles when signing. The DPA also incorporates the UK ICO transfer addendum and Swiss adaptations. Accepting that DPA electronically signs the selected transfer terms between the Customer and Codelynx.

The clauses do not remove the need to assess the destination country's laws, document the transfer assessment, apply any necessary supplementary measures, and verify the safeguards used for onward transfers. If those requirements cannot be met, the affected transfer must be suspended or stopped. Provider agreements do not replace the Customer-to-Codelynx clauses.

The Customer remains responsible for its lawful purposes, notices, recipient selection and tracking choices. See [GDPR in Lumail](/docs/legal/gdpr-in-lumail) and the [Privacy Policy](/legal/privacy) for the disclosed configuration and limitations.

## Rights, erasure and contract questions

Contact [help@codelynx.dev](mailto:help@codelynx.dev) for data-subject assistance, authorized erasure or return, provider information, audit requests and transfer requirements. Ordinary unsubscribe preserves data and is not an erasure request. The scope and completion arrangements must account for archives, snapshots, queues, logs, backups and applicable exceptions.

The accepted DPA contains the binding terms for the signing organization. This explanatory page does not replace that contract or constitute a compliance certification.

---

# Subprocessors
Source: https://lumail.io/docs/legal/subprocessors.md

_Last reviewed: 2026-09-24_

This register describes processors Codelynx uses to operate Lumail. It is an operational disclosure, not a Data Processing Agreement. See [DPA status](/docs/legal/dpa).

Locations are configured or typical processing locations. Edge networks, support access, and provider backups may involve additional countries. Unverified facts are marked unknown.

## Current register

<DocsRegister>
  <DocsRegisterItem
    name="netcup GmbH"
    role="Application host, self-hosted Redis"
    data="App runtime, sessions, cache"
    location="Nuremberg, Germany"
    safeguards="Provider contract for hosting"
    effective="Current"
  />
  <DocsRegisterItem
    name="Neon / Databricks"
    role="PostgreSQL"
    data="Accounts, subscribers, events"
    location="AWS eu-central-1, Frankfurt"
    safeguards="Provider DPA for Codelynx's account; not a customer DPA"
    effective="Current"
  />
  <DocsRegisterItem
    name="Amazon Web Services, Inc. (SES / SNS)"
    role="Email delivery and feedback"
    data="Envelope, content, bounce/complaint"
    location="ap-southeast-2 (Sydney, Australia), eu-central-1 (Frankfurt, Germany) or us-west-1 (N. California, USA), depending on the region selected for each sending domain"
    safeguards="AWS customer agreement for Codelynx; SES region is configured per sending domain, not every worker inspected this review"
    effective="Current"
  />
  <DocsRegisterItem
    name="Cloudflare, Inc."
    role="DNS, CDN, R2 archives"
    data="Traffic metadata; sent-email archives"
    location="Global edge; R2 APAC placement, no EU-only jurisdiction"
    safeguards="Cloudflare terms; R2 region: auto is not residency"
    effective="Current"
  />
  <DocsRegisterItem
    name="Hatchet (self-hosted)"
    role="Background jobs"
    data="Job payloads, including email/workflow data"
    location="Nuremberg, Germany"
    safeguards="Self-hosted software, not a separate external processor"
    effective="Current"
  />
  <DocsRegisterItem
    name="Upstash (QStash)"
    role="Queue orchestration"
    data="Message metadata / payloads"
    location="EU service endpoint"
    safeguards="Provider terms; endpoint location is not a full residency guarantee"
    effective="Current"
  />
  <DocsRegisterItem
    name="PostHog, Inc."
    role="Product analytics / diagnostics"
    data="App events, may include context"
    location="PostHog Cloud EU, Frankfurt"
    safeguards="Provider EU cloud"
    effective="Current"
  />
  <DocsRegisterItem
    name="Stripe, Inc."
    role="Billing"
    data="Account billing identifiers"
    location="Global, including US and EEA"
    safeguards="Stripe DPA for Codelynx's account"
    effective="Current"
  />
  <DocsRegisterItem
    name="Discord Inc."
    role="Operational alerts"
    data="May include a subscriber address on delivery failure"
    location="Global"
    safeguards="No Lumail-specific residency"
    effective="Current"
  />
  <DocsRegisterItem
    name="Google (Gemini / AI)"
    role="Optional AI generation"
    data="Prompts, tool results when features are used"
    location="Unknown / provider default"
    safeguards="Used only when AI features run; fallback may apply"
    effective="When used"
  />
  <DocsRegisterItem
    name="OpenAI (customer-connected)"
    role="Optional BYO assistant"
    data="Prompts when the org connects ChatGPT"
    location="Unknown / customer account"
    safeguards="Independently controlled by the customer"
    effective="When connected"
  />
  <DocsRegisterItem
    name="Tchao"
    role="Optional live support"
    data="Support conversations"
    location="Unknown"
    safeguards="Independently controlled support processor"
    effective="When enabled"
  />
  <DocsRegisterItem
    name="Umami (self-proxied /stats)"
    role="Marketing-site analytics"
    data="Page views"
    location="Same app host / configured analytics backend"
    safeguards="First-party script on public pages"
    effective="Current"
  />
</DocsRegister>

Hashed suppression and delivery-guard records are retained by Lumail after unsubscribe or lawful erasure so the same address is not emailed again.

## Self-hosted vs external

Hatchet and the Lumail application process are self-hosted on netcup. They are not separate legal subprocessors. Neon, AWS, Cloudflare, Upstash, PostHog, Stripe, Discord, Google, OpenAI, and Tchao are external services when used.

## Change procedure

Codelynx gives customers who accept the current DPA **at least 30 days' advance written notice** before adding or replacing a subprocessor, with an objection channel at [help@codelynx.dev](mailto:help@codelynx.dev). The notice describes the provider, processing and intended start date. Customers who have not accepted the DPA should not treat the public list alone as a contractual notification mechanism.

This page will be updated when the register changes. Archived prior versions are the git history of this file until a dedicated archive is published.

---

# Security and incident contact
Source: https://lumail.io/docs/legal/security.md

_Last reviewed: 2026-09-15_

This page describes how to reach Codelynx about security and personal-data incidents. It does not claim ISO, SOC, or other certifications. Organization owners and administrators can accept and download the current [Data Processing Agreement](/docs/legal/dpa).

## Contacts

| Purpose                                              | Address                                                                |
| ---------------------------------------------------- | ---------------------------------------------------------------------- |
| Privacy, DSAR follow-up, incident notice to Codelynx | [help@codelynx.dev](mailto:help@codelynx.dev)                          |
| Product support                                      | [contact@lumail.io](mailto:contact@lumail.io)                          |
| Operator                                             | Codelynx, LLC, 8 The Green STE B, Dover, Delaware 19901, United States |

## Vulnerability reports

Email [help@codelynx.dev](mailto:help@codelynx.dev) with a description, affected URL or API, and reproduction notes. Do not include secrets or customer personal data beyond the minimum needed to demonstrate the issue. We do not operate a paid bug-bounty program.

## Customer incident updates

If Codelynx confirms a personal-data incident that affects subscriber or account data we process for a customer, we aim to notify that customer's organization **without undue delay** after confirmation. That processor-to-controller notice is separate from the customer's own deadline to notify a supervisory authority or data subjects.

We do not promise a fixed 72-hour customer SLA on this page. Statutory controller deadlines are the customer's obligation.

## Measures we actually operate

Documented, evidenced controls — not certifications:

- TLS in transit for the application and APIs
- Organization-scoped data access and API tokens with permissions
- Separate development and production environments
- Passwordless email OTP for Lumail accounts (OTP mail is untracked)
- Sent-email archives stored in Cloudflare R2; lawful erasure deletes known object keys
- Hatchet workers self-hosted in Nuremberg; Postgres in Frankfurt
- Bounce, complaint, and unsubscribe suppression to stop further sending

No service is completely secure. We cannot guarantee that unauthorized access, loss, or disclosure will never occur.

## What to do as a customer

1. Limit API-token permissions and rotate tokens after staff changes.
2. Use separate development and production tokens.
3. Enable native double opt-in before collecting marketing subscribers when that is appropriate for your market.
4. Leave transactional tracking off unless you have a documented reason to enable it.
5. Use lawful erasure for data-subject deletion; do not promise the disabled `DELETE` subscriber API.
6. Report suspected incidents to [help@codelynx.dev](mailto:help@codelynx.dev).

---

# Customer compliance guide
Source: https://lumail.io/docs/legal/customer-compliance.md

_Last reviewed: 2026-09-24_

This guide helps customers assess Lumail. It is not legal advice and not a certification that a particular use complies with GDPR, UK GDPR, Swiss FADP, or ePrivacy rules. Suggested notice wording below is for adaptation only.

## 1. Can you sign a DPA today?

**Yes.** An organization owner or administrator can accept and download the current Article 28 DPA in **Settings → Configuration → Data Processing Agreement**. Version 2026-09-15.1 also incorporates EU SCC Modules Two and Three, the UK ICO Addendum and Swiss adaptations. Lumail does not claim DPF certification or EU-only residency, and the Customer must still complete its transfer assessment and any necessary supplementary measures.

## 2. Who is controller vs processor

You normally determine why subscriber and campaign data is processed and are the controller (or a processor for your own client). Codelynx processes that data to provide Lumail. Codelynx is a separate controller for account, billing, security, support, and its own website analytics.

Your Article 6 lawful basis remains your obligation. Signing the DPA does not establish subscriber consent or make every processing purpose lawful.

## 3. Data map

See the table in [GDPR in Lumail](/docs/legal/gdpr-in-lumail) and the [subprocessor register](/docs/legal/subprocessors). Current configured locations:

- Postgres: Frankfurt
- App + Hatchet: Nuremberg
- SES: Sydney (ap-southeast-2), Frankfurt (eu-central-1) or N. California (us-west-1), depending on the region selected for each sending domain
- R2 archives: APAC, no EU-only jurisdiction
- QStash: EU endpoint
- PostHog: EU cloud

A location hint is not a residency guarantee.

## 4. Tracking vs marketing consent

These are independent choices:

| Flow                              | Tracking default                            | Marketing eligibility                                                                                       |
| --------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| Campaign / newsletter send        | Open + click **on**                         | Existing `SUBSCRIBED` audience                                                                              |
| Transactional API / SDK / MCP / SMTP | Open + click **on** (set both to `false` to disable) | New addresses become `TRANSACTIONAL` unless you enable "add transactional recipients to the marketing list" |
| Native double opt-in confirmation | Open + click **on**                         | Pending until the recipient confirms                                                                        |
| Lumail account OTP                | **Off**                                     | Not marketing consent                                                                                       |

Account creation and an OTP do not establish marketing consent. Enable native DOI in **Settings → Configuration → Double Opt-In** before collecting marketing subscribers when confirmation is appropriate.

Suggested customer notice fragment (adapt, do not copy blindly):

> We send you product and account email you requested. Newsletter and promotional email is sent only if you subscribed and, where required, confirmed. Open and click measurement uses a tracking pixel and rewritten links on campaigns, confirmation emails, and transactional mail. Lumail account sign-in codes are not tracked.

## 5. Export and correction

- List export: organization subscriber export (CSV, 7-day file retention).
- Single contact: dashboard, `get_subscriber`, and related events/emails APIs.
- Correction: PATCH / dashboard update. Email address is immutable on an existing row; create a new contact only if that is lawful.

There is no dedicated single-subject DSAR API beyond those tools.

## 6. Unsubscribe vs lawful erasure

| Action                            | Who                                       | Effect                                                                   |
| --------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------ |
| Unsubscribe                       | Recipient link, API, dashboard            | Stops marketing mail; row and history remain                             |
| `DELETE /api/v1/subscribers/{id}` | API                                       | **405** — disabled                                                       |
| Lawful erasure                    | Organization owner/admin, confirm `ERASE` | Scrubs identifiable data, deletes known archives, tombstones the address |

Use erasure for a data-subject deletion request. It keeps hashed suppression so you do not email the address again. SES, PostHog, backups, and old import files are recorded exceptions — email [help@codelynx.dev](mailto:help@codelynx.dev) if those stores must be chased.

Do not re-import an erased address. The API returns `SUBSCRIBER_ERASED`.

## 7. Your responsibilities

- Publish your own privacy notice and choose legal bases.
- Keep independent consent evidence (notice version, source, timestamp, wording). Lumail `confirmedAt` / `confirmationIp` are not a complete consent record.
- Do not place special-category or children's data in Lumail without a separate assessment.
- Honor objection and unsubscribe without using Lumail history as a reason to keep emailing.
- Do not treat chatbot or docs answers as legal authority.

## 8. Procurement pack

The current procurement pack is: your downloaded signed DPA, this guide, [DPA details](/docs/legal/dpa), [subprocessors](/docs/legal/subprocessors), [security](/docs/legal/security), [privacy](/legal/privacy), and [terms](/legal/terms). Keep your own transfer assessment and evidence for any supplementary measures with that pack. Contact [help@codelynx.dev](mailto:help@codelynx.dev) for provider information or audit requests.

---

# Choose an Email MCP for Claude Code, Then Ship the Launch
Source: https://lumail.io/docs/playbooks/email-mcp-claude-code-product-launch.md

The best email MCP for Claude Code is the one that can complete your actual launch workflow with clear authentication, observable drafts, audience controls, and a deliberate delivery boundary. Lumail fits when you want Claude Code to operate campaigns, subscribers, workflows, and analytics while keeping consequential delivery behind an explicit approval path.

The goal is not to talk about an email platform. The goal is to ship a product change and let your owned list sell it while you keep building.

## What “best email MCP” should mean

Do not choose an MCP by its tool count alone. Test one real release against these criteria:

| Criterion         | Question to ask                                                               |
| ----------------- | ----------------------------------------------------------------------------- |
| Authentication    | Is access tied to the correct account or organization, and can it be revoked? |
| Campaign state    | Can the agent create a saved draft that a human can open and edit?            |
| Audience evidence | Can it show the saved audience rule and recipient estimate?                   |
| Final rendering   | Can it inspect the email with variables, links, and footer resolved?          |
| Delivery boundary | Is scheduling or sending separated from drafting and review?                  |
| Reporting         | Can it read the result back into the same release conversation?               |
| Pricing fit       | Does the billing model match list size and sending frequency?                 |

A polished writing assistant fails this test if it leaves you copying text between tools. A broad MCP can also fail if it hides the exact audience or makes delivery too easy to trigger accidentally.

## Lumail and Kit both have email MCPs

As of August 14, 2026, this is no longer a category with one provider. Kit documents a 65-plus-tool MCP for paid plans, including Cursor and Claude clients. Lumail exposes an organization-aware tool catalog through MCP, with different safety profiles for OAuth plugins and API-token connections.

| Decision              | Lumail                                                                                | Kit                                                                         |
| --------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| Best fit              | Programmable campaigns, graph workflows, transactional email, and send-volume pricing | Creator newsletters, commerce, recommendations, and subscriber-tier pricing |
| Claude Code path      | OAuth plugin with read and safe draft-write tools; full CLI beside it                 | Remote OAuth MCP on paid Kit plans                                          |
| Delivery boundary     | OAuth plugin does not expose send or schedule; use the app or CLI                     | Open-world actions return a Kit confirmation link                           |
| Pricing basis         | Emails delivered; subscribers are unlimited on every plan                             | Active-subscriber tier on paid plans                                        |
| Current catalog claim | 89 organization-scoped tools across the full catalog                                  | 65-plus MCP tools in Kit's current help documentation                       |

Read the [full Lumail versus Kit comparison](/posts/lumail-vs-kit) and Kit's [official MCP guide](https://help.kit.com/en/articles/14827557-how-to-connect-the-kit-mcp-to-your-ai-tools) before buying. Features and prices change; the practical benchmark is one representative launch, not the table alone.

## Connect Lumail to Claude Code

The Lumail plugin installs an OAuth MCP connection plus skills that teach Claude Code the workflow. Paste this into Claude Code:

<AgentPrompt
  label="Install Lumail"
  prompt="Read lumail.io/claude-code/install and install the Lumail plugin for me."
/>

Claude Code runs the marketplace commands, opens the browser sign-in, and verifies the connection. You must approve the organization in the browser.

Open a new Claude Code conversation after installation. Claude Code captures its MCP tool list when a session starts, so the conversation that installed the plugin cannot use newly added tools.

For the manual commands, scope details, removal instructions, and troubleshooting, use the canonical [Claude Code plugin guide](/docs/ai-integration/mcp-claude). Anthropic's [official MCP documentation](https://docs.anthropic.com/en/docs/mcp) explains the protocol from the client side.

## Turn a release into a launch campaign

### 1. Give Claude Code the release evidence

Point it at the changelog, product page copy, approved price, screenshots, and the exact checkout URL. Tell it which source wins when facts conflict.

Use a bounded request:

<AgentPrompt
  label="Create the launch draft"
  prompt="Read the release notes and product-page copy in this repository. Create a launch campaign draft for subscribers tagged product-builders. Use only claims supported by those files, mark anything that needs live verification, and return the campaign ID, subject, preview text, saved audience rule, and recipient estimate. Do not schedule or send."
/>

### 2. Make the reader's result the story

Lead with the work the release removes or the result it unlocks. The stack can appear in one short proof section:

`release evidence → Claude Code → Lumail draft → human review → owned list → offer`

Lumail is the campaign layer. It should not replace the customer's problem, the product proof, or the offer in the narrative.

### 3. Inspect the saved state

Ask Claude Code to read the campaign back. Verify the ID, subject, sender, audience, and every unsupported claim it found. Then open the same draft in Lumail's visual editor for final copy and layout changes.

### 4. Review the rendered email

Ask for the final HTML or text render with variables and the unsubscribe footer resolved. Check links against the production destinations. Repository URLs, preview deployments, and an HTTP 200 are not proof that checkout or onboarding works for a real buyer.

### 5. Deliver through the approved surface

The OAuth plugin intentionally does not register send, schedule, publish, unsubscribe, or delete tools. Finish in the Lumail app, or use the full CLI in your terminal after an explicit send request:

```bash
npx lumail auth login
```

This boundary lets Claude Code prepare nearly all the work without silently converting “draft this” into “contact my list.”

### 6. Bring the result back to the release

After delivery, ask for campaign delivery, clicks, and conversion events, then add the useful result to the next release brief. Do not treat an open rate as revenue. Compare the call-to-action click and purchase path with a similar audience and offer.

## Can Claude Code send a newsletter?

Claude Code can prepare and inspect a Lumail newsletter through the OAuth plugin, but that connection does not expose send or schedule tools. Final delivery happens in the Lumail app or through the Lumail CLI after explicit approval. This separation keeps the draft workflow agent-friendly while preserving a visible human decision before the list is contacted.

## Start from the source-of-truth prompts

The seven-prompt pack already covers the recurring jobs behind this launch: turn a changelog into an issue, rank subject lines, segment clickers, review results, and build the follow-up sequence.

[Get the seven prompts that run a newsletter](/?prompts=1), then use the [Ship with Email hub](/docs/playbooks) for the complete operating loop.

---

# Send a Newsletter from Cursor, Then Ship the Launch
Source: https://lumail.io/docs/playbooks/send-newsletter-from-cursor.md

You can send a newsletter from Cursor by connecting an email MCP, creating a saved campaign from the files already open in your workspace, reviewing its audience and rendered output, and explicitly confirming delivery. With Lumail's API-token MCP, Cursor can work against the same organization data as the dashboard instead of returning copy you must paste into another tool.

The launch remains the story. Cursor is where the evidence lives, and Lumail is the email line in the stack.

## What you need before Cursor writes anything

- a Lumail organization with a verified sending domain and sender;
- a consented subscriber list or a controlled test segment;
- a current API token stored outside the repository;
- the release notes, offer, price, and destination URL;
- one campaign goal and one call to action.

Do not ask Cursor to infer production claims from code. Tell it which files are approved source material and which facts need a live check.

## Connect Cursor to Lumail

The shortest setup is to paste this into Cursor Agent:

<AgentPrompt
  label="Connect Cursor"
  prompt="Read lumail.io/mcp/install and connect this project to the correct Lumail organization. Prefer one organization in a project-level .cursor/mcp.json and never register the same HTTP endpoint twice in one scope."
/>

The machine-readable install guide selects the supported transport, configures the client, and verifies the live tool list. The manual API-token configuration and stdio fallback live in the canonical [Cursor MCP guide](/docs/ai-integration/mcp-cursor).

Cursor's [official MCP documentation](https://docs.cursor.com/context/model-context-protocol) describes remote transports, project and global configuration, tool approvals, and how Agent uses enabled MCP tools. Keep secrets out of project-level files that could be committed.

## The launch workflow inside Cursor

### 1. Extract a campaign brief from the release

Ask Cursor to produce a five-line brief before it calls any email tool:

1. the audience;
2. the change that matters to them;
3. the proof available in the workspace;
4. the offer or next action;
5. missing facts that block the campaign.

Resolve the missing facts. A generated paragraph is not a substitute for a confirmed price, deadline, or feature state.

### 2. Create a draft, not a send

Use a request that makes the boundary explicit:

<AgentPrompt
  label="Prepare the newsletter"
  prompt="Turn the approved changelog into a newsletter campaign for the launch segment. Create it as a draft, return the campaign ID, subject, preview text, audience filter, and estimated recipient count, and list every product claim with its source file. Do not test, schedule, or send yet."
/>

The campaign ID is the handoff between the conversation and the dashboard. Read the same object back before moving on.

### 3. Make the email about the shipped result

Use this sequence:

`old friction → shipped change → concrete proof → one action`

Avoid turning the body into release-note bullets. Show how the release changes the reader's work, then link to the live result. Lumail only needs one line when the stack itself is relevant.

### 4. Render for a real test subscriber

Ask Cursor to render the saved draft with a controlled subscriber so variables and conditional content resolve. Check:

| Check        | Evidence to read back                     |
| ------------ | ----------------------------------------- |
| Identity     | Campaign ID and draft status              |
| Sender       | From and reply-to addresses               |
| Audience     | Saved filter and recipient estimate       |
| Content      | Subject, preview, HTML, and text          |
| Safety       | Unsubscribe link and unresolved variables |
| Destinations | Final URLs, not development links         |

If the render differs from the chat draft, the render wins. It is closer to what the recipient receives.

### 5. Send a test and review it in an inbox

Request a test only after the draft read-back is correct. Open it on desktop and mobile, click every destination, and verify the sender identity. A successful test tool call proves the test was accepted, not that the production campaign is ready.

### 6. Confirm the final schedule

Lumail marks campaign scheduling as a dangerous action because it delivers to every matching recipient and cannot be undone after sending. Over the API-token MCP, the first scheduling call returns a short confirmation challenge. Approve the exact campaign, audience, date, time, and time zone before Cursor repeats the call with that confirmation.

Use this final read-back:

<AgentPrompt
  label="Final preflight"
  prompt="Read the saved campaign again and show me the campaign ID, status, sender, subject, audience rule, recipient count, date, time, timezone, rendered links, and unsubscribe status. Do not schedule until I explicitly approve this exact read-back."
/>

### 7. Read the launch result in the same workspace

After delivery, bring campaign performance back beside the release notes. Compare clicks and purchase events with previous campaigns to a similar audience. Record one change for the next launch rather than rewriting the whole system from one send.

## Can Cursor send a newsletter directly?

Yes. Cursor can use Lumail's API-token MCP to draft, render, test, and schedule a campaign when those tools are enabled for the organization. Scheduling requires explicit confirmation because it contacts real recipients. If you use a reduced OAuth connection instead, complete delivery in the Lumail app or with the CLI according to that connection's exposed tools.

## Keep the prompt system stable

Use the existing pack instead of creating a new prompt library for Cursor. It includes the changelog-to-issue prompt, click-based segmentation, subject-line ranking, re-engagement, reporting, and welcome-sequence jobs used in this workflow.

[Get the seven prompts that run a newsletter](/?prompts=1), or return to the [Ship with Email hub](/docs/playbooks).

---

# Move a Launch List from Kit Without Paying for Dormant Subscribers
Source: https://lumail.io/docs/playbooks/move-launch-list-from-kit.md

If your list is large but you contact only a focused segment each month, a Kit alternative that prices by emails sent can fit better than subscriber-tier billing. Lumail stores unlimited subscribers on every plan and prices by email volume. The safe migration is not a full export on day one: move one representative launch, prove the audience and delivery workflow, and compare a normal month with a peak month.

This page is published by Lumail. Check both providers' current pricing and product documentation before deciding.

## The pricing model is the decision

Kit counts unique active subscribers for its plan tiers. Its free Newsletter plan currently supports up to 10,000 subscribers, while paid Creator starts at $33 per month billed annually for 1,000 subscribers. Kit's [official pricing page](https://kit.com/pricing) is the current source for those terms.

Lumail charges for email volume instead of stored subscriber count:

| Lumail plan | Included volume                      | Current base price |
| ----------- | ------------------------------------ | ------------------ |
| Free        | 3,000 emails per month               | $0                 |
| Premium     | 40,000 emails, then $0.60 per 1,000  | $20 per month      |
| Business    | 400,000 emails, then $0.40 per 1,000 | $200 per month     |

Subscribers are unlimited on every Lumail plan. Verify the current terms on the [Lumail pricing page](/pricing).

## When send-volume pricing wins

Take a list of 50,000 stored subscribers and one monthly launch sent to a 10,000-person segment.

- Lumail prices the 10,000 delivered emails. The other 40,000 stored records do not change the plan.
- Kit prices the paid plan around the active subscriber tier, so the full active list affects the tier even when most people do not receive that launch.

Now invert the case. A 1,000-person list receiving a weekly newsletter sends roughly 4,500 emails per month. That exceeds Lumail's free allowance, while Kit's free Newsletter plan may cover the use case. Send-volume pricing is not universally cheaper; it favors a list that is large relative to monthly sends.

Model three states before moving:

1. a normal month;
2. the largest launch month from the last year;
3. the list size and send frequency you expect twelve months from now.

The [full Lumail versus Kit comparison](/posts/lumail-vs-kit) covers commerce, recommendations, MCP access, workflows, and transactional email alongside price.

## Kit now has an MCP too

Do not choose on outdated “AI-native versus no MCP” claims. Kit documents a 65-plus-tool OAuth MCP for paid plans that works with Cursor, Claude, ChatGPT, and other compatible clients. It can read account data, draft broadcasts, manage subscribers, and route consequential actions through confirmation in the Kit app.

Lumail's full catalog currently contains 89 organization-scoped tools. Its Claude Code OAuth plugin exposes read and safe draft-write operations, while the API-token MCP and CLI cover broader operations with confirmation controls.

Choose on the actual work:

| Need             | Better question                                                              |
| ---------------- | ---------------------------------------------------------------------------- |
| Creator commerce | Do you need Kit's paid products and recommendation network?                  |
| Product email    | Do newsletters, workflows, and transactional messages need one organization? |
| Agent access     | Which client, tool scope, and confirmation model match the team?             |
| Cost             | Is list size or monthly delivered volume the stronger driver?                |

## Migrate one launch before the whole list

### 1. Export complete consent state

Export active subscribers, unsubscribes, bounced addresses, complaints, tags, custom fields, and the source of consent. A migration that copies only active emails can accidentally make suppressed people contactable again.

### 2. Import a controlled segment

Start with internal addresses and a small consented segment that represents the real launch audience. Preserve the tags needed to recreate the campaign filter.

### 3. Authenticate the sending domain

Configure the required DNS records, verify the sender, and leave Kit live while records propagate. Do not move acquisition forms or active automations yet.

### 4. Rebuild one campaign and one automation

Choose the workflow that matters commercially: the launch campaign, welcome sequence, or buyer follow-up. Recreate its trigger, waits, branches, goal, and exit conditions. Test success, negative, and early-exit paths.

### 5. Run the same launch preflight

Read back the campaign ID, sender, audience rule, recipient count, links, unsubscribe state, and schedule. Send a test. Confirm delivery only after the rendered result matches the approved offer.

### 6. Compare operation as well as price

Measure:

- monthly cost in a normal and peak month;
- time from source material to approved draft;
- manual steps between agent, editor, and send;
- deliverability and complaint signals;
- clicks and attributed purchases;
- the time required to answer one subscriber-level support question.

Only then decide whether to move the remaining audience and automations.

## What Lumail does not replace

Lumail does not provide Kit's paid newsletter products, digital-product checkout, or recommendation network. If those drive audience growth or revenue, keep them in the stack or choose Kit. A lower email-platform bill is not a win if the migration removes the system that acquires or monetizes the audience.

## Use one prompt pack on either side of the migration

The same seven jobs remain: drafting, subject lines, click-based segments, re-engagement, changelog conversion, performance review, and a welcome sequence. Keep the prompts stable while changing the delivery system so the comparison is easier to interpret.

[Get the seven prompts that run a newsletter](/?prompts=1), then use the [Claude Code launch playbook](/docs/playbooks/email-mcp-claude-code-product-launch) or [Cursor newsletter playbook](/docs/playbooks/send-newsletter-from-cursor) for the first representative campaign.

---

# Ship with Email
Source: https://lumail.io/docs/playbooks.md

Your owned list sells while you ship when email is part of the release process, not a separate marketing project. The repeatable loop is simple: turn the change into a campaign, choose the people it helps, review the rendered email, deliver it with explicit approval, and feed the results into the next release.

Lumail is one line in that stack. Your product, checkout, source material, audience, and offer remain the system around it.

## Start with the result, not the email tool

The result is not “a newsletter was sent.” The result is that the right people understood what shipped and had one obvious next step.

Write these five facts before opening an editor:

| Decision         | Example for a product launch                           |
| ---------------- | ------------------------------------------------------ |
| Commercial event | A paid feature is available today                      |
| Audience         | Trial users who reached the relevant limit             |
| Promise          | Finish the task without the old workaround             |
| Evidence         | Release notes, screenshots, pricing, and the live page |
| One action       | Upgrade or try the feature                             |

If the offer, proof, or audience is unclear, an agent only produces a faster vague campaign. Fix the launch brief first.

## Pick the playbook that matches the work

These pages share one operating model but answer different questions. Use one canonical guide for each job instead of copying the same setup across several pages.

### [Choose an email MCP for Claude Code, then ship the launch](/docs/playbooks/email-mcp-claude-code-product-launch)

Use this when the release work already lives in Claude Code. The guide covers what “best email MCP” should mean, how Lumail and Kit differ, and how to turn repository evidence into a reviewable campaign draft.

### [Send a newsletter from Cursor](/docs/playbooks/send-newsletter-from-cursor)

Use this when the brief, changelog, screenshots, and product code are already open in Cursor. The guide keeps drafting, rendering, audience review, confirmation, and reporting in one observable sequence.

### [Move a launch list from Kit without paying for dormant subscribers](/docs/playbooks/move-launch-list-from-kit)

Use this when subscriber-tier pricing no longer matches how often you contact the list. The guide moves one representative launch first, preserves suppression state, and compares a normal month with a peak month before a full migration.

## The six-stage shipping loop

### 1. Collect launch evidence

Give the agent primary material: the actual release notes, current pricing, screenshots, product page, and any claims already approved. A repository is useful evidence, but code alone does not prove that production works. Mark anything that still needs live verification.

### 2. Define the audience in plain language

“People likely to buy” is not a segment. “Active subscribers tagged `course-creator` who clicked a launch email in the last 90 days and have not purchased” is reviewable.

Ask for both the saved filter and the estimated recipient count. If either is surprising, stop before drafting.

### 3. Create a draft from the source material

The agent should return a campaign ID, subject, preview text, body, audience rule, and the facts it could not verify. Draft state is the right default because it is inspectable and reversible.

The product appears only where it explains the mechanism. A useful launch story is about what changed for the reader, how the release was promoted, and what happened next—not a feature inventory disguised as a tutorial.

### 4. Review the rendered email

Review what a recipient will receive, not only markdown in a chat. Check:

- sender and reply-to address;
- subject and preview text;
- personalization and conditional blocks;
- every link and button destination;
- unsubscribe behavior;
- mobile rendering;
- the exact recipient estimate.

The [campaign rendering workflow](/posts/ai-email-marketing-for-newsletters) explains why draft creation and final delivery should stay separate.

### 5. Approve delivery explicitly

Read back the campaign ID, sender, audience, recipient count, date, time, and time zone. A request to draft is never permission to send. A successful tool call is not proof of inbox delivery.

### 6. Close the loop with revenue evidence

Compare the launch with similar sends. Opens can expose subject-line or deliverability problems, but clicks and attributed purchases are closer to the commercial result. Record what changed, what remained constant, and what the next campaign will test.

## A citable answer to “how do I launch with email?”

To launch with email, define one audience and one action, build the campaign from verified release evidence, render and review the saved draft, approve the exact recipient set and schedule, then compare clicks and purchases with previous launches. Keep the email platform as one component in the shipping stack, not the story itself.

## Use the same seven prompts every week

Do not rebuild the operating system from scratch for every release. The existing prompt pack covers drafting, subject lines, click-based segments, re-engagement, changelog conversion, performance review, and a welcome sequence.

[Get the seven prompts that run a newsletter](/?prompts=1). It is the only prompt pack used throughout these playbooks.

## Related documentation

- [AI integrations and live tool categories](/docs/ai-integration)
- [Claude Code plugin setup](/docs/ai-integration/mcp-claude)
- [Cursor MCP setup](/docs/ai-integration/mcp-cursor)
- [Campaign and automation safety model](/posts/ai-email-marketing-for-newsletters)
- [Lumail pricing](/pricing)

---

# Guides
Source: https://lumail.io/docs/guides.md

Paths, not endpoint catalogs. For product concepts see [Documentation](/docs). For REST and the SDK see [API Reference](/docs/api-reference/v2).

## Start here

- [Create an API token](/docs/tutorials/create-api-token)
- [Send transactional email over SMTP](/docs/tutorials/smtp-transactional-emails)
- [React Email templates](/docs/tutorials/react-email)
- [Connect Claude or Cursor](/docs/ai-integration)

## Migrate

- [Migration to high deliverability emails](/docs/tutorials/migration-to-high-deliverability-emails) — branding, why-you-receive, double opt-in, and workflow timing
- [Migration to V2](/docs/api-reference/v2/migration) — rewrite a v1 REST or SDK integration to `/api/v2`
- [Move a launch list from Kit](/docs/playbooks/move-launch-list-from-kit)
- [Send a newsletter from Cursor](/docs/playbooks/send-newsletter-from-cursor)
- CLI: `npx lumail migrate import --file export.json` imports sending domains from a Lumail export. Contacts are not imported.

---

# Migration to high deliverability emails
Source: https://lumail.io/docs/tutorials/migration-to-high-deliverability-emails.md

Inbox placement is no longer "SPF + DKIM and hope". Gmail and Yahoo expect a recognizable sender and a sentence that explains why this specific person got this specific email — including one-time passwords and receipts. Lumail's editor scores those two things. This page is the migration path.

If you only need the confirmation switch, use [Enable Double Opt-In](/docs/tutorials/enable-double-opt-in). Come back here for branding, the why-line, OTP/transactional copy, and workflow timing.

If an AI agent is preparing the complete account, use [Prepare Your Lumail Account for Lumail 2.0.0](/docs/ai-integration/prepare-account-2-0). It includes the strict transactional-versus-marketing routing rule, Workflow draft sequence, and approval boundary.

## What "migrated" looks like

Every email that leaves the organization — campaign, workflow, confirmation, OTP, receipt — has:

1. **Visible branding** in the first screen of the body (logo, organization name, or your domain as its own first line).
2. **A why-you-receive sentence** in the same language as the email. Not the unsubscribe line.
3. **A real postal address** (`{{org-adresse}}` / `@org-adresse`) and an unsubscribe link on marketing mail.
4. **Native double opt-in** on public signups, with a branded confirmation email.
5. **Workflows that enroll `SUBSCRIBED` contacts**, not pending ones.

The content checker awards **5 points** for branding and **10 points** for the why-line. The default "Don't want to receive these emails?" footer does **not** count as a why-line. See [Content Deliverability Checker](/docs/features/content-deliverability).

## 1. Add clear branding

Put the brand where a human sees it without scrolling. The checker accepts any of:

- A **logo** block with a real image URL (filename-only or a hidden link does not count)
- The **organization name**, including the `{{org-name}}` / `@org-name` variable
- Your **domain alone** as the first text block (`melvynx.com`)

Snippets are checked too. Put the header (logo + name) in a snippet and reuse it in campaigns, workflow emails, and the confirmation template so the brand cannot drift.

Do this on OTP and transactional HTML as well. A bare "Your code is 482191" from `noreply@` with no logo and no product name looks like phishing. Same sender display name, same logo, same domain in the first lines.

## 2. Say why they received this email

Gmail/Yahoo want a causal sentence. Language does not matter. Shape does.

| Channel | Line that passes |
| --- | --- |
| Newsletter / campaign | `You're receiving this email because you signed up at example.com` |
| French list | `Tu reçois ce mail car tu t'es inscrit sur example.com` |
| Confirmation | `You're receiving this because you just signed up and need to confirm your email` |
| OTP / magic link | `You're receiving this because you requested a sign-in code for example.com` |
| Receipt / invoice | `You're receiving this because you placed an order on example.com` |
| Password reset | `You're receiving this because a password reset was requested for your example.com account` |

Write it in the email language. Put it near the footer, not only in preview text.

Does **not** count:

- `Don't want to receive these emails?`
- `Unsubscribe` alone
- A legal address with no causal phrase

Agents writing campaign/workflow bodies must add this sentence and visible branding. Call `get_skill({ type: "docs", page: "tutorials/migration-to-high-deliverability-emails" })` before rewriting a migrated list.

Transactional and SMTP sends (`html`, `markdown`, React Email) are not scored by the editor. Add the two lines yourself. Same rule as campaigns.

## 3. Turn on native double opt-in and brand the confirmation

Public capture pages, forms, and API signups should confirm the inbox before marketing starts.

**UI:** Settings → Configuration → **Double opt-in** → **Edit confirmation email**.

**Agent (admin/owner):**

```
get_organization_settings
update_organization_settings({ doubleOptInEnabled: true })
get_confirmation_email
update_confirmation_email({ subject, preview, template })
```

The confirmation body must include `{{confirmUrl}}` (button `url` or link `href`). Missing it is rejected. Use the same logo/name snippet and a why-line. Subject/preview should name the brand (`Confirm your Acme subscription`), not a generic "Confirm your email address" if you can do better.

What the switch does, and what it does not:

- New public signups become `PENDING_CONFIRMATION` and get one confirmation email (24h token).
- Existing `SUBSCRIBED` contacts stay subscribed. Enabling DOI is not a retroactive purge.
- Confirmation emails send from the organization's default sender.
- `skipDoubleOptIn: true` is for trusted backends (checkout, server sync) only. Never on a public form.
- `triggerWorkflows: false` skips the confirmation email. It is not a substitute for `skipDoubleOptIn`.
- Do not build a custom confirmation state machine with the transactional API.

Full source table: [Enable Double Opt-In](/docs/tutorials/enable-double-opt-in#what-uses-double-opt-in).

## 4. Set workflows up for confirmation

With DOI on, tag-triggered and "subscriber created" workflows **wait until the click**. Tags attached at signup are replayed after confirm so the welcome sequence enrolls a real `SUBSCRIBED` contact.

Do this:

1. `get_organization_settings` — if DOI is off and the list is public, turn it on first.
2. `get_skill({ type: "workflow" })` then `get_workflow` (keep `updatedAt`).
3. `configure_workflow_draft` with the full graph. EMAIL steps need complete inline content: branding, why-line, `{{unsubscribeUrl}}`, `{{org-adresse}}`.
4. Do not publish until the user confirms. `publish_workflow` is dangerous.

Do not:

- Enroll `PENDING_CONFIRMATION` contacts as if they were live.
- Put `skipDoubleOptIn` on a capture-page or client-side signup.
- Publish `subscriber.tag.added` with empty `tagIds`.
- Call `create_campaign` for a workflow email.

Manual enrollment (`add_subscribers_to_workflow`) still requires a subscribed contact. `dryRun: true` first.

## 5. Other current recommendations

These are the other recent product rules that belong in the same migration, not a second project:

| Item | Do |
| --- | --- |
| Physical address | `@org-adresse` in the footer. CAN-SPAM / inbox filters. |
| Unsubscribe | `{{unsubscribeUrl}}`. Type `/` → Unsubscribe Link. Translate the label if the email is not English. |
| No shorteners | `bit.ly`, `t.co`, and friends fail the checker. Use your own domain. Lumail already tracks clicks. |
| Tracking domain | Settings → Configuration → Tracking domains. Dedicated host for `/r/<id>` tracked links, with DNS verification. Shared tracking is the fallback. |
| Sending subdomain | Prefer `mail.example.com` over the root. Isolate reputation. |
| Content checker | Fix the eleven checks before send. **Unspam** rewrites trigger words. |
| Transactional list | Keep **Add transactional recipients to the marketing list** off unless you have consent. OTP/receipt recipients must not become `SUBSCRIBED` by accident. |
| Trusted imports | CSV import writes `SUBSCRIBED` and does not send confirmation. Clean the file first. |
| Footer snippet | One branded footer (why-line + address + unsubscribe) reused everywhere. Settings → Language does not rewrite snippet copy. |

DNS, bounces, and DMARC stay on [Deliverability](/docs/deliverability) and [Email Domains](/docs/domains/email-domains). This page is content and consent, not SPF.

## Agent sequence

For a migration request ("make our emails high deliverability"):

1. `get_skill({ type: "docs", page: "tutorials/migration-to-high-deliverability-emails" })`
2. `get_writing_style` then `get_email_snippets` — reuse the header/footer if they exist, otherwise create them.
3. `get_organization_settings` → enable DOI if public signups are on.
4. Brand `update_confirmation_email` (must keep `{{confirmUrl}}`).
5. Rewrite campaign/workflow bodies with branding + why-line. `get_skill({ type: "campaign" })` / `get_skill({ type: "workflow" })` first.
6. For OTP/transactional templates in the customer's app, give them the two sentences to paste. `send_email` does not invent them.

Members can read settings and the confirmation template. Only owners/admins can flip DOI or save the confirmation email.

## Related documentation

- [Prepare Your Lumail Account for Lumail 2.0.0](/docs/ai-integration/prepare-account-2-0)
- [Enable Double Opt-In](/docs/tutorials/enable-double-opt-in)
- [Content Deliverability Checker](/docs/features/content-deliverability)
- [Account Verification](/docs/features/account-verification)
- [GDPR in Lumail](/docs/legal/gdpr-in-lumail)
- [Workflows](/docs/workflows)
- [Send React Email](/docs/tutorials/react-email)
- [Language](/docs/features/language)

---

# Clear Branding
Source: https://lumail.io/docs/tutorials/clear-branding.md

Clear branding answers one question immediately: **who sent this email?** A reader should not need to inspect the sender address, follow a link, or reach the unsubscribe footer to identify your business.

## Watch the walkthrough

This short walkthrough shows acceptable header and footer branding, what anonymous email looks like, and why the same identity should appear in every campaign.

<DocsTella
  src="https://www.tella.tv/video/vid_cmttdso3f000a0agm5a9pa0g0/embed?b=0&title=0&a=1&loop=0&t=0&muted=0&wt=0"
  title="How to add clear branding to your emails"
/>

If the player does not load, [watch the Clear Branding walkthrough on Tella](https://www.tella.tv/video/vid_cmttdso3f000a0agm5a9pa0g0/view).

## What clear branding means

Your email has clear branding when the visible content connects the message to a recognizable organization. Use at least one strong identifier:

- Your company or product name
- Your logo, with useful alt text
- Your website domain displayed as text

The sender name and address still matter, but they do not replace branding inside the email. Inbox interfaces can shorten or hide sender details, and forwarded emails can lose their original context.

## Put it in the header when possible

The header is the clearest place because readers see it before the message body. A simple logo and company name are enough.

**Good header example**

> **Acme**  
> Weekly product notes from acme.com

In the Lumail editor, add a logo image or a first text block containing your organization name. You can type `@` and insert the **Organization name** variable so the name stays aligned with your settings.

Keep the header compact. Clear branding does not require a large banner, a slogan, or a navigation bar.

## Footer-only branding is acceptable

If a prominent header does not fit your personal or text-first style, identify the business in the footer. Include the company name, domain, or logo before the compliance links.

**Good footer example**

> Marie from Acme  
> [acme.com](https://acme.com)  
> 123 Main Street, Paris  
> Unsubscribe (linked to `{{unsubscribeUrl}}`)

The footer must remain readable. Do not hide the identity in tiny, low-contrast text.

## Examples that work

| Email style            | Clear branding                                                          |
| ---------------------- | ----------------------------------------------------------------------- |
| Product newsletter     | Logo and **Acme** at the top                                            |
| Personal founder email | “Marie from Acme” near the opening or signature                         |
| Plain-text update      | “Acme — acme.com” in the signature                                      |
| Promotional campaign   | Consistent product name in the header and company details in the footer |

The design can change between campaigns. The identity should not.

## What does not work

These examples leave the reader guessing:

- “Hey, it’s Marie” with no company, product, domain, or logo
- A generic message signed only with a first name
- A logo used only as an image filename or hidden link destination
- A company name that appears only in the sender address
- Different brand names from one campaign to the next without explaining the relationship

If a subscriber cannot connect the email to the organization they recognize, the branding is not clear enough.

## Clear branding and reason for sending are different

Lumail checks these separately:

| Check                  | Question it answers    | Example                                                          |
| ---------------------- | ---------------------- | ---------------------------------------------------------------- |
| **Clear branding**     | Who sent this?         | “Acme — acme.com”                                                |
| **Reason for sending** | Why am I receiving it? | “You’re receiving this email because you signed up at acme.com.” |

Add both. Branding establishes identity; the reason-for-sending line reminds the reader how they joined your audience.

## Keep it consistent

Recognition grows through repetition. Once you choose a visible name, logo, and domain, reuse them across campaigns, workflows, and transactional messages.

Consistency helps readers recognize you faster and reduces the chance that a legitimate email feels unexpected. It supports trust and deliverability, but it does not replace sender authentication, consent, list hygiene, or an unsubscribe link.

## Check it in Lumail

1. Open your email in the editor.
2. Add a visible organization name, domain, or logo in the header or footer.
3. Open **Content checks** in the toolbar.
4. Confirm that **Branding** shows `5/5`.
5. Add a reason-for-sending sentence if that separate check is still missing.

The branding checker recognizes your organization name, the `@org-name` variable, a domain displayed in the first text block, or a logo image block. A hidden URL or image filename alone does not pass.

## Final checklist

- A new reader can identify the sender in a few seconds
- The company name, domain, or logo is visible in the email content
- The same identity appears across every email type
- The branding is readable on mobile and in dark mode
- The reason-for-sending line explains why the subscriber is on the list
- The footer also includes your physical address and unsubscribe link

## Related documentation

- [Reason for Sending](/docs/tutorials/reason-for-sending) — explain why the subscriber receives the email
- [Content Deliverability Checker](/docs/features/content-deliverability) — all eleven pre-flight checks
- [Migration to high deliverability emails](/docs/tutorials/migration-to-high-deliverability-emails) — branding, consent, and sending setup
- [Enable Double Opt-In](/docs/tutorials/enable-double-opt-in) — confirm subscriber ownership before campaigns

---

# Reason for Sending
Source: https://lumail.io/docs/tutorials/reason-for-sending.md

A reason-for-sending line answers the subscriber's question: **why am I receiving this email?** It connects the message to a real action they took, such as creating an account, joining a newsletter, downloading a resource, or buying a product.

## Watch the walkthrough

This two-minute walkthrough shows a clear account example, a version for multiple signup sources, and vague explanations that should be avoided.

<DocsTella
  src="https://www.tella.tv/video/vid_cmtte0eek00000agmhpiigfk5/embed?b=0&title=0&a=1&loop=0&t=0&muted=0&wt=0"
  title="How to write a clear reason for sending"
/>

If the player does not load, [watch the Reason for Sending walkthrough on Tella](https://www.tella.tv/video/vid_cmtte0eek00000agmhpiigfk5/view).

## Use action plus brand

The most reliable formula is:

> You're receiving this email because you **[action]** on or through **[brand or domain]**.

Both parts matter:

- **Action** reminds the subscriber what they did.
- **Brand or domain** gives them a name they can recognize and verify.

For example:

> You're receiving this email because you created an account on Lumail.io.

That sentence is specific enough for the reader to check whether they recognize Lumail and remember creating an account.

## Templates you can copy

Choose the template that matches the real source of the subscriber. Replace the example names and domains with your own.

| Source             | Example reason                                                                         |
| ------------------ | -------------------------------------------------------------------------------------- |
| Newsletter form    | You're receiving this email because you subscribed to the Acme newsletter at acme.com. |
| Account creation   | You're receiving this email because you created an Acme account.                       |
| Lead magnet        | You're receiving this email because you downloaded the Email Checklist from acme.com.  |
| Webinar            | You're receiving this email because you registered for an Acme webinar.                |
| Quiz or calculator | You're receiving this email because you used the pricing calculator on acme.com.       |
| Purchase           | You're receiving this email because you purchased an Acme product.                     |
| Waitlist           | You're receiving this email because you joined the Acme product waitlist.              |

The action does not need to be long. It needs to be true and recognizable.

## When subscribers come from multiple sources

You do not need a unique footer for every subscriber if several legitimate sources feed the same audience. Use a broader sentence that lists the real possibilities:

> You're receiving this email because you subscribed through acme.com, used one of our calculators, completed a quiz, or joined an Acme program.

Keep the list short and concrete. Do not include sources you do not use, and do not hide an unknown acquisition source behind a generic sentence.

If you know the exact source for each subscriber, segment the audience or use a custom field so the explanation can stay precise.

## Examples that do not work

These sentences repeat the obvious without explaining the original action:

- “You're receiving this email because you subscribed to my emails.”
- “You're receiving this email for marketing purposes.”
- “You're receiving this email because you gave me your email.”
- “You are on this list.”

They are also missing a recognizable company, product, or domain. Replace them with the actual signup event and the brand that collected the address.

## Reason for sending and clear branding are different

Lumail checks these separately:

| Check                  | Question it answers      | Example                           |
| ---------------------- | ------------------------ | --------------------------------- |
| **Reason for sending** | Why am I receiving this? | “You created an account on Acme.” |
| **Clear branding**     | Who sent this?           | “Acme — acme.com”                 |

A strong reason-for-sending sentence often contains the brand, but the rest of the email should still be clearly branded. See [Clear Branding](/docs/tutorials/clear-branding) for header, footer, and consistency examples.

## Where to place it

Put the sentence in the footer, close to the unsubscribe link and your physical address. This keeps the explanation available without interrupting the main message.

A complete footer can look like this:

> You're receiving this email because you subscribed to the Acme newsletter at acme.com.  
> Acme, 123 Main Street, Paris  
> Unsubscribe: `{{unsubscribeUrl}}`

Use readable text and sufficient contrast. Do not hide the explanation in an image or make it difficult to find.

## Other languages

Write the reason in the same language as the email. Lumail recognizes common reason-for-sending patterns in English, French, Spanish, Italian, German, and Portuguese.

| Language | Example                                                              |
| -------- | -------------------------------------------------------------------- |
| English  | You're receiving this email because you signed up at acme.com.       |
| French   | Tu reçois cet e-mail car tu t'es inscrit sur acme.com.               |
| Spanish  | Recibes este correo porque te registraste en acme.com.               |
| German   | Du erhältst diese E-Mail, weil du dich bei acme.com angemeldet hast. |

The exact wording can change. Preserve the action and the recognizable brand.

## Check it in Lumail

1. Open your email in the editor.
2. Add the reason-for-sending sentence to the footer.
3. Open **Content checks** in the toolbar.
4. Confirm that **Reason for sending** shows `10/10`.
5. Confirm separately that **Branding** shows `5/5`.

The default unsubscribe sentence — “Don't want to receive these emails?” — does not count because it does not explain how the subscriber joined.

## Final checklist

- The sentence describes a real subscriber action
- It names the company, product, channel, or domain they recognize
- Multiple sources are listed only when they genuinely feed the audience
- The wording matches the language of the email
- The sentence is visible near the unsubscribe link
- The email also contains clear branding, a physical address, and an unsubscribe link

This explanation improves recognition and reduces confusion, but it does not create consent by itself. Keep accurate signup records and only email people you are allowed to contact.

## Related documentation

- [Clear Branding](/docs/tutorials/clear-branding) — make the sender identifiable
- [Content Deliverability Checker](/docs/features/content-deliverability) — all eleven pre-flight checks
- [Enable Double Opt-In](/docs/tutorials/enable-double-opt-in) — confirm subscriber ownership before campaigns
- [Migration to high deliverability emails](/docs/tutorials/migration-to-high-deliverability-emails) — complete sending setup

---

# Enable Double Opt-In
Source: https://lumail.io/docs/tutorials/enable-double-opt-in.md

Double opt-in keeps people off your marketing list until they prove they own the inbox. The signup is the first opt-in. The confirmation click is the second.

Enable it before you collect subscribers in a market that expects consent evidence (France, the EEA, and most public capture pages). Lumail's native flow is the supported one — do not invent a custom confirmation state machine with the transactional API.

## Watch the complete walkthrough

This five-minute walkthrough shows the complete journey: enable the setting, customize the confirmation email, find a pending subscriber, confirm from the inbox, and see a post-confirmation workflow send.

<DocsTella
  src="https://www.tella.tv/video/vid_cmtqwcz0l00090agm7sxe8k8q/embed?b=0&title=0&a=1&loop=0&t=0&muted=0&wt=0"
  title="How to enable double opt-in in Lumail"
/>

If the player does not load, [watch the double opt-in walkthrough on Tella](https://www.tella.tv/video/vid_cmtqwcz0l00090agm7sxe8k8q/view).

## What it does

With **Enable Double Opt-In** on:

1. A new subscriber is created as `PENDING_CONFIRMATION`.
2. Lumail sends your confirmation email from the organization's default sender.
3. The subscriber clicks `{{confirmUrl}}`.
4. Their status becomes `SUBSCRIBED`. Lumail stores `confirmedAt` and `confirmationIp`, writes a subscribed timeline event, and starts tag-triggered workflows.

Until that click, they do not receive campaigns or workflow marketing emails. Filter **Subscribers → Pending Confirmation** to see who is waiting.

## How the confirmation works

```
Signup (form, API, capture page, integration)
        ↓
PENDING_CONFIRMATION  +  confirmation email (24h token)
        ↓
Click {{confirmUrl}}  →  /confirm?token=…
        ↓
SUBSCRIBED  +  confirmedAt / confirmationIp  +  workflows
```

The confirmation link is a single-use token URL. A second subscribe POST for the same pending address does not send a second email or rotate the token. If they never click, they stay pending — turning the setting off later does not subscribe them for you.

Existing `SUBSCRIBED` contacts are left alone when you enable the setting. Only **new** subscribers go pending.

## Enable it

1. Open **Settings → Configuration**. The URL is `/orgs/[your-org]/settings/configuration`.
2. Find the **Double Opt-In** card.
3. Turn **Enable Double Opt-In** on.
4. Click **Save**.

![Double Opt-In card on Settings → Configuration](/docs/tutorials/double-opt-in-settings.png)

_The first card on Configuration. The switch is organization-wide._

| Control | What it does |
| --- | --- |
| **Enable Double Opt-In** | New subscribers are `PENDING_CONFIRMATION` and get a confirmation email |
| **Change email template** | Opens the confirmation email editor (only after the switch is on) |
| **Save** | Persists the switch. The email template is saved separately inside the editor |

The card below it — **Add transactional recipients to the marketing list** — is a different path. Turning that on creates receipt/OTP recipients as `SUBSCRIBED`. Double opt-in does not apply there.

## Customize the confirmation email

With the switch on, click **Change email template**.

![Confirmation email editor with subject, preview, and {{confirmUrl}} button](/docs/tutorials/confirmation-email-editor.png)

_Subject, preview, and body. The blue button must point at `{{confirmUrl}}`._

| Data point | Detail |
| --- | --- |
| **Subject / Preview** | Inbox subject and preview text. Defaults are `Confirm your email address` |
| **`{{confirmUrl}}`** | Required. Type `@` and pick **Confirmation URL**, or `/confirm` to insert the confirmation button |
| **`{{name}}` / fields** | Same subscriber variables as campaigns. `@` opens the menu |
| **Snippets** | Type `/` to insert an organization snippet |
| **Default sender** | Confirmation emails always send from the organization's default sender |
| **Save email** | Writes the template. Missing `{{confirmUrl}}` falls back to the default template |

The recipient confirmation page (`You're all set`) follows **Settings → Language**, not the email body language. Translate the email yourself.

## Test the complete journey

Use a test inbox after saving the setting and email:

1. Submit a new email address through your public form or capture page.
2. In **Subscribers**, clear any active status filter, or filter by **Pending Confirmation**, to find the new contact. A pending contact is not shown when the list is filtered to subscribed contacts.
3. Open the contact and confirm that **Confirm your email address** appears in their recent email activity.
4. Open that email in the test inbox and click its confirmation button or link.
5. The confirmation page says **You're all set**. Back in Lumail, the contact is now `SUBSCRIBED`; any eligible subscriber-created or tag-added workflow can enroll and send its first email.

If the contact is still pending, check that the template contains `{{confirmUrl}}` on a button URL or link, then save the email template again. Do not replace it with a URL from a different subscriber: confirmation links are single-use and recipient-specific.

## After they confirm

The subscriber timeline shows that they **confirmed their email via the double opt-in link and subscribed to the list**. The confirmation-link click is labeled as such, not as a generic URL click.

Workflows that start on **subscriber created** or **tag added** wait until confirmation. Tags attached at signup are replayed after the click so tag-triggered workflows enroll a real `SUBSCRIBED` contact.

Campaigns only target `SUBSCRIBED`. Pending contacts never enter a send.

## What uses double opt-in

| Source | New subscriber | Confirmation email |
| --- | --- | --- |
| Capture page | `PENDING_CONFIRMATION` | Sent |
| Subscribers API / SDK (`POST /api/v1/subscribers` or `/api/v2/subscribers`) | `PENDING_CONFIRMATION` | Sent (unless `triggerWorkflows: false`) |
| API / SDK with `skipDoubleOptIn: true` | `SUBSCRIBED` | Not sent — no confirmation token |
| ClickFunnels / Systeme.io | `PENDING_CONFIRMATION` | Sent |
| Dashboard **Add Subscriber** | `PENDING_CONFIRMATION` | Sent only if **Trigger workflows** is on |
| Dashboard **Add Subscriber** with **Skip double opt-in** | `SUBSCRIBED` | Not sent |
| CSV import | `SUBSCRIBED` by default | Not sent — import writes status directly |
| Transactional API / SMTP / `lumail.emails.send` | `TRANSACTIONAL` or `SUBSCRIBED` | Ignored. Not a custom double-opt-in mechanism |
| Resubscribe of `UNSUBSCRIBED` / `TRANSACTIONAL` | `SUBSCRIBED` | Skipped |

`skipDoubleOptIn` defaults to `false`. New organizations have double opt-in on; existing organizations keep whatever they already stored. Trusted backends (checkout, server-side sync) should pass `skipDoubleOptIn: true` so those contacts stay `SUBSCRIBED` and tag-triggered workflows can enroll. Public forms must omit the flag.

`triggerWorkflows: false` still creates the pending subscriber when DOI is on. It skips the confirmation email. It is not a substitute for `skipDoubleOptIn`. Use `skipDoubleOptIn` for trusted sources; do not use either flag on a public form.

For the legal and evidence limits of `confirmedAt` / `confirmationIp`, see [GDPR in Lumail](/docs/legal/gdpr-in-lumail#native-and-custom-double-opt-in).

## Related documentation

- [GDPR in Lumail](/docs/legal/gdpr-in-lumail) — when to enable native confirmation
- [WordPress Plugin](/docs/integrations/wordpress) — public `[lumail_form]`, never skips DOI
- [Create Subscriber API](/docs/api-reference/api-subscribers-post) — signup payload and `triggerWorkflows`
- [Language](/docs/features/language) — confirmation page locale
- [Subscriber Events](/docs/features/subscriber-events) — `SUBSCRIBED` timeline events
- [Workflows](/docs/workflows) — enrollments start after confirmation
- [Account Verification](/docs/features/account-verification) — DOI also helps sender health
- [Migration to high deliverability emails](/docs/tutorials/migration-to-high-deliverability-emails) — branding, why-you-receive, OTP, and workflow setup

---

# Automatic list cleanup
Source: https://lumail.io/docs/tutorials/automatic-list-cleanup.md

Automatic list cleanup removes inactive subscribers so the list stays healthy. It is off until an owner or admin calculates a preview and turns the switch on from that receipt.

Do not invent a custom inactivity unsubscribe with the API. Use this setting.

## Watch the complete walkthrough

This three-minute walkthrough shows the complete journey: the eligibility rules, preview before enable, analytics after cleanup, the re-engagement email, a cleaned subscriber, and the recovery page.

<DocsTella
  src="https://www.tella.tv/video/vid_cmu271jnv00070agm5l6x9vd8/embed?b=0&title=0&a=1&loop=0&t=0&muted=0&wt=0"
  title="How to use automatic email list cleanup in Lumail"
/>

If the player does not load, [watch the automatic list cleanup walkthrough on Tella](https://www.tella.tv/video/vid_cmu271jnv00070agm5l6x9vd8/view).

## What it does

With **Automatic list cleanup** on, Lumail regularly scans `SUBSCRIBED` contacts and unsubscribes anyone who:

- has not opened or clicked a marketing email in the interval you chose (30, 90, or 180 days)
- has not come back through a cleanup recovery link in that same interval
- has been subscribed for at least 7 days
- has at least 5 delivered marketing emails
- has a trustworthy subscription history

Everyone else is kept or deferred. Preview never changes a contact. Enabling only turns the setting on — the next scan applies it.

```
Settings → Configuration → Automatic list cleanup
        ↓
Preview (dry run + 10-minute receipt)
        ↓
Turn the switch on
        ↓
Daily scan (pages of 100)
        ↓
UNSUBSCRIBED  +  optional re-engagement email
        ↓
Click {{subscribeUrl}}  →  /resubscribe?token=…
        ↓
SUBSCRIBED again  (or confirmation email if double opt-in is on)
```

## Enable it

1. Open **Settings → Configuration → Automatic list cleanup**. The URL is `/orgs/[your-org]/settings/configuration/automatic-cleanup`.
2. Pick **Remove after** (30, 90, or 180 days).
3. Leave **Send a re-engagement email** on if you want one last email after they are removed.
4. Click **Preview**. Nobody is unsubscribed.
5. Wait until the card shows how many would be removed (or **Nobody matches.**).
6. Turn the switch on, or click **Turn on cleanup**. The receipt expires after 10 minutes — preview again if it does.
7. Click **Save** only if you changed the interval or re-engagement toggle without enabling.

![Automatic list cleanup card on Settings → Configuration](/docs/tutorials/list-cleanup-settings.png)

_Preview on the left, Save on the right. The switch stays locked until a completed preview exists._

| Control | What it does |
| --- | --- |
| **Automatic list cleanup** | Off until a completed preview receipt exists. On starts scans. Off disables immediately — no preview needed |
| **Remove after** | Inactivity window: 30, 90, or 180 days. Default 90 |
| **Send a re-engagement email** | After unsubscribe, send one transactional email with `{{subscribeUrl}}`. Default on |
| **Edit email** | Opens the re-engagement editor (only when the re-engagement switch is on) |
| **Preview** | Saves the interval + re-engagement toggle, then counts who would be removed. Does not enable |
| **Save** | Persists interval and re-engagement. Cannot turn the feature on |

Turning the main switch on without a completed preview shows **Preview first.** Changing the interval or re-engagement toggle after a preview invalidates the receipt — preview again.

When the preview finishes, the card shows **Nobody matches.** or **N of M contacts would be unsubscribed.** plus sample addresses. Enabling still requires that completed receipt, even if the count is zero.

## Who is never removed

| Keep / defer | Why |
| --- | --- |
| Open or click in the interval | Recent activity |
| Came back via `/resubscribe` in the interval | Recent recovery |
| Subscribed less than 7 days ago | New subscription |
| Fewer than 5 delivered marketing emails | Not enough sends to judge inactivity |
| Subscription history unknown | Missing or untrustworthy `SUBSCRIBED` event |

Each daily scan evaluates current subscribers against these criteria using recorded activity in the selected window. Historical tracking incidents do not restart a 30-, 90-, or 180-day waiting period. If a new tracking incident occurs during a scan, the affected batch is not finalized and the scan is retried.

Transactional, pending, already unsubscribed, and bounced contacts are outside the candidate set.

## Analytics after you enable

Once the switch is on, the same page shows cleanup analytics for the last 7 / 30 / 90 days:

| KPI | Meaning |
| --- | --- |
| **Cleaned** | Inactive subscribers unsubscribed automatically |
| **Recovered** | Share of cleaned subscribers who came back through the recovery link |
| **Re-engagement emails** | Recovery emails sent to cleaned subscribers |
| **Next scan** | When the next full pass is scheduled, plus the last cleanup time |

Below the KPIs: a **Cleaned subscribers** chart (unsubscribed vs recovered) and a **Recent cleanups** table (per day: cleaned, emailed, recovered).

Empty states tell you why nothing happened:

- a scan is paging the list (100 contacts at a time) and nobody has been unsubscribed yet
- the last full scan found nobody to remove
- the first scan is still being scheduled

Cleaned contacts appear here only after someone is actually unsubscribed — not when the preview finishes.

## Re-engagement email

Optional. Default subject is `Are you still interested?`. Default preview is `Stay on the list or we'll stop sending.`

Unsubscribe is applied first. The email is sent afterwards on the confirmation/transactional path — not as a campaign. If sending fails or is skipped, they stay `UNSUBSCRIBED`. Lumail logs the failure.

The template must include a real button or link with `{{subscribeUrl}}`. Type `@` and pick **Re-subscribe URL**, or put `{{subscribeUrl}}` on a button URL. Missing it refuses the save.

![Re-engagement email editor with subject, preview, and Stay subscribed button](/docs/tutorials/list-cleanup-email-editor.png)

_Subject, preview, and body. The button must point at `{{subscribeUrl}}`._

| Data point | Detail |
| --- | --- |
| **Subject / Preview** | Inbox subject and preview text |
| **`{{subscribeUrl}}`** | Required. Opens `/resubscribe?token=` (24-hour single-use token) |
| **`{{unsubscribeUrl}}`** | Optional. Default footer includes it |
| **`{{name}}` / fields** | Same subscriber variables as campaigns. `@` opens the menu |
| **Snippets** | Type `/` to insert an organization snippet |
| **Default sender** | Always the organization's default sender |
| **Save email** | Writes the template. Missing `{{subscribeUrl}}` is rejected |

On a cleaned contact, the timeline shows **was unsubscribed by automatic list cleanup**. Open **View email** on the re-engagement send to read the exact message they received.

## Recovery page

The re-engagement link opens `/resubscribe?token=…`.

1. The page shows **Stay subscribed?** and a prefilled email field.
2. They confirm the address and click **Stay subscribed**. A different email is rejected — the field exists so they can see and confirm the address, not change it.
3. If they are already `SUBSCRIBED`, it says so and stops.
4. If **double opt-in** is on, status becomes `PENDING_CONFIRMATION` and they get the confirmation email. Campaigns wait for that click.
5. If double opt-in is off, they become `SUBSCRIBED` immediately. Unsubscribe guards are cleared. Workflows are not published from this recovery.

The token lasts 24 hours and is single-use. A second click on a used or expired link fails.

## Timeline

Filter a subscriber timeline by **Automatic list cleanup**.

| Event | Copy |
| --- | --- |
| `AUTOMATIC_LIST_CLEANUP` (admitted) | was queued for automatic list cleanup |
| `AUTOMATIC_LIST_CLEANUP` (finalized) | was removed by automatic list cleanup |
| `UNSUBSCRIBED` (`source: AUTOMATIC_LIST_CLEANUP`) | was unsubscribed by automatic list cleanup |
| `SUBSCRIBED` (`source: AUTOMATIC_LIST_CLEANUP_RECOVERY`) | came back from automatic list cleanup |

Unsubscribe reason stored on the contact is **Automatic list cleanup**.

## After you enable

A full pass runs **once a day**. The scan pages 100 subscribed contacts per organization until the list is finished, then waits 24 hours.

Disable anytime with the switch. No preview required. Existing unsubscribes stay unsubscribed — disable does not restore them.

Do not enable from the raw configuration API. Ask Lumail can preview and enable only with a completed receipt (`preview_list_cleanup` then `enable_list_cleanup`). `update_list_cleanup_settings` can disable or change the interval; it cannot turn the feature on.

## Related documentation

- [Enable Double Opt-In](/docs/tutorials/enable-double-opt-in) — recovery sends a confirmation email when DOI is on
- [Subscriber Events](/docs/features/subscriber-events) — timeline events
- [Bot detection](/docs/features/bot-detection) — how opens and clicks are recorded
- [GDPR in Lumail](/docs/legal/gdpr-in-lumail) — consent and unsubscribe evidence
- [Account Verification](/docs/features/account-verification) — list hygiene and sender health

---

# Create an API Token
Source: https://lumail.io/docs/tutorials/create-api-token.md

This tutorial walks you through creating an API token in Lumail. API tokens allow your applications, integrations, and automations to securely access your organization's data.

## Why use API tokens?

- **Programmatic access** — Automate subscriber management, send emails, and sync data from your applications.
- **Secure authentication** — Tokens are scoped to your organization and can be revoked anytime.
- **Integration ready** — Connect Lumail with tools like Make.com, Zapier, n8n, or your custom backend.
- **No user credentials** — Keep your account secure by using tokens instead of username/password.

## Prerequisites

- A Lumail account with an organization.
- Owner or admin role in the organization.

## Step 1 — Navigate to API Keys

1. Log in to your [Lumail dashboard](https://lumail.io/orgs/default).
2. Click on **API Keys** in the sidebar.
3. Click the **Generate API Key** button.

![Generate token button](https://codelynx.mlvcdn.com/images/2026-01-05/clipboard_image_1767593749.png)

## Step 2 — Name your token

Give your token a descriptive name to identify its purpose:

- `Production API` — For your production application
- `Development` — For local development and testing
- `Zapier Integration` — For Zapier automation
- `Webhook Handler` — For processing workflow webhooks

![Name your token dialog](https://codelynx.mlvcdn.com/images/2026-01-05/clipboard_image_1767593767.png)

Click **Create** to generate your token.

## Step 3 — Copy and save your token

Your token will be displayed only once. Copy it immediately and store it securely.

![Token created dialog](https://codelynx.mlvcdn.com/images/2026-01-05/clipboard_image_1767593788.png)

The token format is `lum_` followed by a 64-character string:

```
lum_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6
```

Store it in your environment variables:

```bash
# .env.local (never commit this file)
LUMAIL_API_TOKEN=lum_your_token_here
```

## Step 4 — Use your token

Include the token in the `Authorization` header of your API requests:

```javascript
const response = await fetch("https://lumail.io/api/v1/subscribers", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.LUMAIL_API_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    email: "user@example.com",
    name: "Jane Doe",
    tags: ["newsletter"],
  }),
});

const data = await response.json();
console.log(data);
```

## Security best practices

1. **Never commit tokens to version control** — Use environment variables or secret managers.
2. **Use separate tokens** — Create different tokens for development, staging, and production.
3. **Rotate tokens regularly** — Generate new tokens periodically and delete old ones.
4. **Delete unused tokens** — Remove tokens you no longer need from the settings page.

## Managing tokens

To view, or delete existing tokens:

1. Go to [API Keys](https://lumail.io/orgs/default/api-keys).
2. View all active tokens with their creation dates.
3. Click the delete icon to revoke a token.

Deleting a token immediately revokes access — any applications using it will fail authentication.

## Related Documentation

- [API Tokens Reference](/docs/api-reference/api-tokens) - Complete token documentation
- [Create Subscriber API](/docs/api-reference/api-subscribers-post) - Add subscribers
- [Send Email API](/docs/api-reference/api-emails-send) - Send transactional emails
- [Update Subscriber API](/docs/api-reference/api-subscribers-patch) - Update subscriber data
- [Tags API](/docs/api-reference/api-tags-get) - Manage tags
- [Track Events API](/docs/api-reference/api-events-post) - Track subscriber activity
- [Dynamic Promo Codes](/docs/tutorials/dynamic-promo-codes) - Use tokens in webhooks
- [Webhook Step](/docs/workflows/workflow-webhook-step) - Authenticate webhook callbacks

---

# Send Transactional Email with SMTP
Source: https://lumail.io/docs/tutorials/smtp-transactional-emails.md

Use Lumail's SMTP endpoint when an application can only send email through SMTP and cannot call `POST /api/v1/emails` directly. Common examples include auth providers, billing tools, no-code platforms, and legacy backends.

SMTP messages are treated as transactional by default. They use the same priority delivery lane as the transactional email API, so auth codes, magic links, password resets, and operational emails are not queued behind newsletter or campaign batches.

## When to Use SMTP

Use SMTP for:

- authentication codes and magic links
- password resets
- account invitations
- receipts and invoices
- product notifications
- other low-volume operational emails

Do not use the SMTP endpoint for newsletters, broadcasts, or marketing campaigns. Use Lumail campaigns and workflows for bulk sending so unsubscribes, segmentation, scheduling, and campaign reporting stay correct.

## Prerequisites

Before configuring SMTP, the organization needs:

1. A verified Lumail email domain.
2. A Lumail API token.
3. An SMTP-capable application that supports STARTTLS on port `587`.
4. A `From` address on the verified domain.

The API token and the verified domain must belong to the same Lumail organization.

## Step 1 - Verify a Sending Domain

The `From` address must use a verified email domain in Lumail.

1. Open your Lumail organization.
2. Go to **Settings** -> **Domains**.
3. Add your sending domain, for example `yourdomain.com`.
4. Add the SPF and DKIM DNS records shown by Lumail.
5. Wait until the domain status is **Verified**.

If your verified domain is `yourdomain.com`, valid sender examples include:

```text
auth@yourdomain.com
support@yourdomain.com
noreply@yourdomain.com
```

Lumail rejects SMTP messages when the `From` address does not belong to a verified email domain in the same organization as the API token.

## Step 2 - Create an API Token

The SMTP password is a Lumail API token, not your Lumail account password.

1. Open your Lumail organization.
2. Go to **Settings** -> **API Tokens**.
3. Click **Generate Token**.
4. Name it clearly, for example `Production SMTP`.
5. Copy the token immediately and store it in your application's secret manager.

The token starts with `lum_`.

## Step 3 - Configure Your SMTP Client

Use these settings in your external application:

| Setting  | Value                                      |
| -------- | ------------------------------------------ |
| Host     | `smtp.lumail.io`                           |
| Port     | `587`                                      |
| Security | STARTTLS                                   |
| Username | Your organization slug, or any label       |
| Password | Your Lumail API token                      |
| From     | An address on a verified Lumail domain     |
| To       | The recipient address for the notification |

The username is used as a label in SMTP logs. The password is what authenticates the request.

## Supabase Auth Example

For Supabase Auth custom SMTP, configure:

```text
Host: smtp.lumail.io
Port: 587
Security: STARTTLS
Username: your-org-slug
Password: lum_your_api_token
Sender email: auth@your-verified-domain.com
```

Use the same sender address in Supabase email templates. The sender domain must already be verified in Lumail.

## Nodemailer Example

```javascript
import nodemailer from "nodemailer";

const transport = nodemailer.createTransport({
  host: "smtp.lumail.io",
  port: 587,
  secure: false,
  requireTLS: true,
  auth: {
    user: "your-org-slug",
    pass: process.env.LUMAIL_API_TOKEN,
  },
});

await transport.sendMail({
  from: "Auth Team <auth@your-verified-domain.com>",
  to: "user@example.com",
  subject: "Your login code",
  text: "Your login code is 123456.",
});
```

> **Test-mode recipients.** Fixture addresses such as `user@example.com` never send real email. See [Test-mode recipients](/docs/api-reference/test-mode).

## Test the Connection

You can test the SMTP endpoint with `swaks`:

```bash
swaks \
  --server smtp.lumail.io \
  --port 587 \
  --tls \
  --auth LOGIN \
  --auth-user your-org-slug \
  --auth-password lum_your_api_token \
  --from auth@your-verified-domain.com \
  --to user@example.com \
  --header "Subject: Lumail SMTP test" \
  --body "This email was sent through Lumail SMTP."
```

A successful response ends with:

```text
250 Queued 1 message(s)
```

That means Lumail accepted the email and queued it on the transactional priority lane. Final inbox placement still depends on the receiving mailbox and downstream delivery provider.

## Message Behavior

| Behavior       | Default                                                            |
| -------------- | ------------------------------------------------------------------ |
| Delivery lane  | Transactional priority                                             |
| Recipients     | 1 recipient per SMTP message                                       |
| Attachments    | Rejected                                                           |
| Size limit     | 1 MB                                                               |
| Link tracking  | Enabled                                                            |
| Open tracking  | Enabled                                                            |
| Content format | HTML is preserved when present; otherwise text is sent as Markdown |

Send one SMTP message per recipient. If your application needs bulk sending, use Lumail campaigns or workflows instead.

## Troubleshooting

| SMTP response | Meaning                                  | Fix                                                         |
| ------------- | ---------------------------------------- | ----------------------------------------------------------- |
| `535`         | Invalid API token                        | Check the token starts with `lum_` and has not been deleted |
| `530`         | Authentication required                  | Enable SMTP AUTH in your application                        |
| `550`         | Invalid sender, recipient, or message    | Verify the `From` domain and message content                |
| `552`         | Message too large or attachment rejected | Keep the message under 1 MB and remove attachments          |
| `452`         | Temporary quota, rate, or plan issue     | Retry later or check organization limits                    |
| `451`         | Lumail API temporarily unavailable       | Retry with exponential backoff                              |

If your provider asks for SSL/TLS on port `465`, choose STARTTLS on port `587` instead. Lumail's SMTP endpoint is exposed on `587`.

## Related Documentation

- [SMTP Endpoint Reference](/docs/api-reference/smtp) - Connection settings and response codes
- [Create an API Token](/docs/tutorials/create-api-token) - Generate the token used as the SMTP password
- [Email Domains](/docs/domains/email-domains) - Verify the domain used in your `From` address
- [Send Transactional Email API](/docs/api-reference/api-emails-send) - Prefer this API when your application supports HTTP

---

# Send React Email Templates with Lumail
Source: https://lumail.io/docs/tutorials/react-email.md

[React Email](https://react.email) lets you write email templates as React components and render them to email-safe HTML. Lumail accepts that HTML directly through `html` on `POST /api/v2/emails`, so React Email covers the template layer and Lumail covers sending, domain authentication, subscriber records, and delivery tracking.

## When to Use This

Use React Email with Lumail for transactional email: receipts, password resets, magic links, invitations, order confirmations, and product notifications.

Campaigns are a different content pipeline: they accept `MARKDOWN` and `MAILY`, not `HTML`. Newsletters and broadcasts stay in Lumail campaigns and workflows so unsubscribes, segmentation, and campaign reporting remain correct.

## Prerequisites

1. A verified Lumail email domain, so your `from` address is allowed.
2. A Lumail API token (starts with `lum_`). See [Create API Token](/docs/tutorials/create-api-token).
3. A Node.js project that can render React on the server.

## Step 1 - Install the Packages

```bash
pnpm add lumail @react-email/components @react-email/render
```

`@react-email/components` provides the email-safe primitives. `@react-email/render` turns a component into an HTML string. `lumail` is the [TypeScript SDK](/docs/sdk/introduction).

To preview templates locally while you build them, add the React Email dev server:

```bash
pnpm add -D react-email
```

## Step 2 - Write the Template

Create `emails/order-confirmation.tsx`:

```tsx
import {
  Body,
  Button,
  Container,
  Head,
  Heading,
  Hr,
  Html,
  Link,
  Preview,
  Section,
  Text,
} from "@react-email/components";

type OrderConfirmationProps = {
  name: string;
  orderNumber: string;
  total: string;
  receiptUrl: string;
};

export default function OrderConfirmation({
  name,
  orderNumber,
  total,
  receiptUrl,
}: OrderConfirmationProps) {
  return (
    <Html>
      <Head />
      <Preview>Your order {orderNumber} is confirmed</Preview>
      <Body
        style={{ backgroundColor: "#f6f6f6", fontFamily: "Arial, sans-serif" }}
      >
        <Container
          style={{
            maxWidth: "600px",
            padding: "24px",
            backgroundColor: "#ffffff",
          }}
        >
          <Heading style={{ fontSize: "22px", color: "#111111" }}>
            Thanks for your order, {name}
          </Heading>
          <Text style={{ fontSize: "15px", color: "#333333" }}>
            Order <strong>{orderNumber}</strong> is confirmed. Total: {total}.
          </Text>
          <Section style={{ margin: "24px 0" }}>
            <Button
              href={receiptUrl}
              style={{
                backgroundColor: "#111111",
                color: "#ffffff",
                padding: "12px 20px",
                borderRadius: "6px",
              }}
            >
              View your receipt
            </Button>
          </Section>
          <Hr />
          <Text
            style={{ fontSize: "12px", color: "#666666", textAlign: "center" }}
          >
            Questions?{" "}
            <Link href="mailto:support@yourdomain.com">Contact us</Link>
          </Text>
        </Container>
      </Body>
    </Html>
  );
}
```

Pass per-recipient data as React props. A transactional send targets one recipient, so the values are already known at render time and you keep full type safety.

## Step 3 - Render to HTML

`render()` from `@react-email/render` v2 is asynchronous and returns the full HTML document.

```typescript
import { render } from "@react-email/render";

import OrderConfirmation from "@/emails/order-confirmation";

const html = await render(
  <OrderConfirmation
    name="Jane"
    orderNumber="A-1042"
    total="$99.00"
    receiptUrl="https://yourdomain.com/receipts/A-1042"
  />,
);
```

Render in a `.tsx` file so JSX compiles. If your send logic lives in a `.ts` file, keep rendering in a separate `.tsx` module and import the resulting function.

## Step 4 - Send Through Lumail

```typescript
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY! });

const { id } = await lumail.emails.send({
  to: "customer@example.com",
  from: "orders@yourdomain.com",
  subject: "Your order A-1042 is confirmed",
  html,
});
```

> **Test-mode recipients.** Fixture addresses such as `user@example.com` never send real email. See [Test-mode recipients](/docs/api-reference/test-mode).

The `preview` field is ignored for `HTML` content - only the Markdown and Tiptap pipelines inject it. The preheader comes from React Email's `<Preview>` component in your template.

Each recipient of a transactional send becomes a Lumail contact if they are not one already (`TRANSACTIONAL` unless **Add transactional recipients to the marketing list** is on), and addresses on the blocklist are rejected before queueing.

The equivalent raw API call:

```bash
curl -X POST https://lumail.io/api/v2/emails \
  -H "Authorization: Bearer $LUMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "customer@example.com",
    "from": "orders@yourdomain.com",
    "subject": "Your order A-1042 is confirmed",
    "html": "<html>...rendered react email...</html>"
  }'
```

A success response means Lumail queued the email on the transactional priority lane. See [Send Transactional Email](/docs/api-reference/api-emails-send) for the full field list and response shape.

## Complete Example

A single server function that renders and sends:

```tsx
import { render } from "@react-email/render";
import { Lumail } from "lumail";

import OrderConfirmation from "@/emails/order-confirmation";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY! });

export async function sendOrderConfirmation(order: {
  email: string;
  name: string;
  orderNumber: string;
  total: string;
  receiptUrl: string;
}) {
  const html = await render(
    <OrderConfirmation
      name={order.name}
      orderNumber={order.orderNumber}
      total={order.total}
      receiptUrl={order.receiptUrl}
    />,
  );

  return lumail.emails.send({
    to: order.email,
    from: "orders@yourdomain.com",
    subject: `Your order ${order.orderNumber} is confirmed`,
    html,
  });
}
```

## React Props vs Lumail Variables

Lumail replaces `{{variable}}` placeholders in HTML content before sending. Both mechanisms work, and they are good at different things.

| Data                                                     | Use              |
| -------------------------------------------------------- | ---------------- |
| Order totals, names, URLs, anything your app already has | React props      |
| `{{unsubscribeUrl}}`, `{{onlineUrl}}`                    | Lumail variables |
| Lumail custom subscriber fields                          | Lumail variables |

Lumail owns the unsubscribe and view-in-browser URLs, so those must stay as placeholders.

Two limits are worth knowing before you rely on placeholders in a React Email template:

- Placeholder names must be word characters. Hyphenated organization variables such as `{{org-name}}` are not substituted in `HTML` content and are delivered literally. Pass organization values as React props instead.
- `{{unsubscribeUrl}}` resolves to an empty string on transactional sends, because a transactional email has no unsubscribe link. It is only meaningful when `transactional` is false.

See [Send Email in HTML](/docs/api-reference/api-emails-html) for the full variable list.

### Escaping Braces in JSX

`{{unsubscribeUrl}}` inside JSX is parsed as a JavaScript expression containing an object literal, not as text. Always pass it as a string:

```tsx
// Correct
<Link href={"{{unsubscribeUrl}}"}>Unsubscribe</Link>
<Text>Hello {"{{name}}"}</Text>

// Broken - JSX evaluates the braces
<Link href={{unsubscribeUrl}}>Unsubscribe</Link>
```

## What Lumail Does to Your HTML

Your rendered document is sent through mostly untouched. Lumail applies these transformations:

- **Variable replacement** - `{{...}}` placeholders are substituted with subscriber values.
- **Open tracking** - a 1x1 pixel is inserted just before `</body>` when open tracking is on. React Email always emits a `</body>`, so the pixel lands inside the document.
- **Unsubscribe fallback** - for non-transactional sends only, if the HTML contains no `{{unsubscribeUrl}}`, Lumail appends an unsubscribe block to the end of the string, after your closing `</html>`. Include `{{unsubscribeUrl}}` in your template to control where it renders.
- **Plain text version** - generated by stripping HTML tags from your content.
- **Click tracking** - link rewriting for `HTML` content targets Markdown-style `[text](url)` links, so `<a href>` URLs in a React Email template are delivered unchanged. Your original links stay intact, which is usually what you want for password resets and magic links.

Set `tracking.open: false` if you do not want the pixel at all.

## Notes on Styling

- Inline styles are the safest option and are what the React Email primitives use by default.
- `<Tailwind>` works, since it inlines utility classes at render time. Responsive variants and pseudo-classes still fall back to a `<style>` block in `<head>`, which many clients ignore.
- Keep the body width at or below 600px.
- The auto-generated plain text version comes from a tag strip, so text inside `<style>` blocks can leak into it. Prefer inline styles and keep `<head>` CSS minimal.
- Preview across clients before shipping a new template.

## Preview Templates Locally

With `react-email` installed as a dev dependency, add a script:

```json
{
  "scripts": {
    "email": "email dev --dir ./emails"
  }
}
```

Then run `pnpm email` and open the local preview to iterate on the template before wiring up the send.

## Related

- [Send Transactional Email](/docs/api-reference/api-emails-send) - full endpoint reference
- [Send Email in HTML](/docs/api-reference/api-emails-html) - variables and email-safe HTML rules
- [SDK Resources](/docs/sdk/resources) - other SDK operations
- [Send Transactional Email with SMTP](/docs/tutorials/smtp-transactional-emails) - for tools that cannot call the API

---

# Dynamic Promo Codes with Webhooks
Source: https://lumail.io/docs/tutorials/dynamic-promo-codes.md

This tutorial shows you how to generate unique, personalized promo codes for each subscriber in your email sequences. Instead of using a static code like `LINKEDIN25` for everyone, you'll create individual codes with expiration dates to drive urgency and make subscribers feel special.

## Why use dynamic promo codes?

- **FOMO effect** — Time-limited codes create urgency and encourage immediate action.
- **Exclusivity** — Unique codes make subscribers feel like they're getting special treatment.
- **Tracking** — Individual codes let you track which subscribers convert.
- **Credibility** — Personalized codes feel more authentic than generic discount codes.

## How it works

1. Subscriber joins your workflow (via tag trigger or form submission).
2. Workflow webhook step sends subscriber data to your server.
3. Your server generates a unique coupon code with expiration.
4. Your server updates the subscriber's custom field with the coupon.
5. Email steps in your workflow display the personalized code using variables.

## Prerequisites

- A Lumail organization with an active workflow.
- One of the following to handle the webhook:
  - **No-code**: Make.com, Zapier, or n8n scenario
  - **Low-code**: Vercel, Cloudflare Workers, or Val.town function
  - **Custom**: Your existing backend server
- Optional: A payment provider (Stripe, LemonSqueezy) for validated coupons — or just generate unique tracking codes.

## Step 1 — Create a custom field for the coupon

Before setting up the workflow, create a custom field to store the promo code:

1. Go to your Lumail dashboard.
2. Navigate to **Settings → Custom Fields**.
3. Create a new field:
   - **Name**: `promo-code` (or `atrakt-coupon` if using Atrakt)
   - **Type**: Text

This field will store each subscriber's unique promo code.

![Screenshot](https://codelynx.mlvcdn.com/images/2026-01-05/clipboard_image_1767593393.png)

## Step 2 — Set up your workflow

Create a workflow that triggers when subscribers join your sequence:

1. Go to **Workflows** in your Lumail dashboard.
2. Create a new workflow or edit an existing one.
3. Add a **Trigger** (e.g., "Subscriber added with tag: `new-lead`").
4. Add a **Webhook Step** right after the trigger.

![create webhook step](https://codelynx.mlvcdn.com/images/2026-01-05/clipboard_image_1767593465.png)

### Configure the Webhook Step

In the webhook step configuration:

- **Webhook URL**: Your server endpoint (e.g., `https://your-app.com/api/generate-coupon`)
- **Webhook Secret**: A secret key for authentication (optional but recommended)

![configure webhook step](https://codelynx.mlvcdn.com/images/2026-01-05/clipboard_image_1767593527.png)

The webhook `POST`s this body. `payload` stays nested. When a secret is set, it is also sent as the `x-lumail-signature` header.

```json
{
  "subscriberId": "sub_123",
  "subscriberEmail": "john@example.com",
  "organizationId": "org_xyz",
  "workflowId": "wf_789",
  "stepId": "step_456",
  "secret": "your-secret",
  "payload": {}
}
```

## Step 3 — Build the coupon generation endpoint

Create a serverless function or API route that:

1. Receives the webhook payload from Lumail.
2. Generates a unique coupon code with expiration.
3. Creates the coupon in your payment provider (optional).
4. Updates the subscriber in Lumail with the coupon code.

### Example with Next.js and Stripe

Create `app/api/generate-coupon/route.ts`:

```tsx
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();

    // Verify webhook secret
    if (body.secret !== process.env.LUMAIL_WEBHOOK_SECRET) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }

    const { subscriberEmail, subscriberId } = body;

    // Generate unique code
    const uniqueCode = `WELCOME-${subscriberId.slice(-6).toUpperCase()}`;

    // Set expiration to 7 days from now
    const expiresAt = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60;

    // Create coupon in Stripe
    await stripe.coupons.create({
      id: uniqueCode,
      percent_off: 25,
      duration: "once",
      redeem_by: expiresAt,
      max_redemptions: 1,
    });

    // Create promotion code for the coupon
    await stripe.promotionCodes.create({
      coupon: uniqueCode,
      code: uniqueCode,
      max_redemptions: 1,
      expires_at: expiresAt,
    });

    // Calculate expiration date for display
    const expirationDate = new Date(expiresAt * 1000).toLocaleDateString(
      "en-US",
      {
        month: "long",
        day: "numeric",
        year: "numeric",
      },
    );

    // Update subscriber in Lumail with the coupon
    await fetch(`https://lumail.io/api/v1/subscribers/${subscriberEmail}`, {
      method: "PATCH",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.LUMAIL_API_TOKEN}`,
      },
      body: JSON.stringify({
        fields: {
          "promo-code": uniqueCode,
          "promo-expires": expirationDate,
        },
        triggerWorkflows: false,
      }),
    });

    return NextResponse.json({ success: true, code: uniqueCode });
  } catch (error) {
    console.error("Coupon generation failed:", error);
    return NextResponse.json(
      { error: "Failed to generate coupon" },
      { status: 500 },
    );
  }
}
```

## Step 4 — Use the promo code in your emails

After the webhook step completes, the subscriber has a `promo-code` custom field. Use it in your email content with Lumail variables:

In the editor, type `@` to insert variables — your custom fields will appear in the list.

You can also add this promo code inside the URL, in the editor you can write `https://example.com/?code={{promo-code}}`.

## Step 5 — Test the flow

1. **Deploy your webhook endpoint** to a publicly accessible URL.
2. **Add the webhook URL** to your Lumail workflow step.
3. **Trigger a test subscriber** by adding the trigger tag to a test email.
4. **Check the subscriber** in Lumail dashboard — verify the `promo-code` field is populated.
5. **Review the email** sent — confirm the code appears correctly.

### Debugging tips

- Check your server logs for webhook receipt confirmation.
- Verify the Lumail API token has permission to update subscribers.
- Ensure the custom field name matches exactly (`promo-code` is case-sensitive).
- Test with `triggerWorkflows: false` to avoid infinite loops.

## Summary

By combining Lumail's webhook steps with your backend logic:

1. **Webhook step** triggers your coupon generation endpoint.
2. **Your server** creates unique codes and updates subscriber custom fields via the [PATCH API](/docs/api-reference/api-subscribers-patch).
3. **Email steps** display personalized codes using `{{promo-code}}`.

This creates a seamless experience where each subscriber receives a unique, time-limited promo code — driving urgency, tracking conversions, and making your offers feel exclusive.

## Related Documentation

- [Webhook Step Reference](/docs/workflows/workflow-webhook-step) - Webhook configuration
- [Update Subscriber API](/docs/api-reference/api-subscribers-patch) - Update subscribers with custom fields
- [Variables in Emails](/docs/features/variables) - Use custom fields in email content
- [API Tokens](/docs/api-reference/api-tokens) - Authenticate webhook callbacks
- [Create API Token Tutorial](/docs/tutorials/create-api-token) - Generate tokens for webhooks
- [Workflows Overview](/docs/workflows) - Learn about workflow automation

---

# Tutorials
Source: https://lumail.io/docs/tutorials.md

Learn how to combine Lumail features to create powerful email marketing automations. These tutorials walk you through complete implementations with code examples.

## Getting Started

### [Migration to high deliverability emails](/docs/tutorials/migration-to-high-deliverability-emails)

Bring campaigns, OTP, and transactional mail up to current inbox hygiene: visible branding, a why-you-receive line, native double opt-in, and workflows that wait for confirmation.

### [Clear Branding](/docs/tutorials/clear-branding)

Make every email instantly recognizable with a consistent company name, domain, or logo in the header or footer.

### [Reason for Sending](/docs/tutorials/reason-for-sending)

Explain the specific action that put someone on your list and name the brand or domain they recognize.

### [Enable Double Opt-In](/docs/tutorials/enable-double-opt-in)

Require new subscribers to confirm their email before they receive campaigns. Native confirmation, pending status, and the confirmation email editor.

### [Automatic list cleanup](/docs/tutorials/automatic-list-cleanup)

Unsubscribe people who have not opened or clicked for 30, 90, or 180 days. Off by default. Preview before you enable.

### [Create API Token](/docs/tutorials/create-api-token)

Step-by-step guide to generating API tokens for authenticating your Lumail API requests. Essential for all API integrations.

### [WordPress Plugin](/docs/integrations/wordpress)

Install `[lumail_form]` on WordPress. Token stays on the server; submissions hit `POST /api/v2/subscribers`.

## Build & Connect

### [Send React Email Templates with Lumail](/docs/tutorials/react-email)

Write transactional email templates as React components, render them to HTML, and send them through the Lumail transactional email API.

### [Dynamic Promo Codes with Webhooks](/docs/tutorials/dynamic-promo-codes)

Create unique, time-limited promo codes for each subscriber using workflow webhooks. Add FOMO and exclusivity to your email sequences with personalized discounts.

## Related Documentation

- [API Reference](/docs/api-reference) - Complete API documentation
- [Workflows](/docs/workflows) - Automate email sequences
- [Integrations](/docs/integrations) - WordPress, ClickFunnels, SystemIO, and more

---

# Prepare Your Account for Lumail 2.0
Source: https://lumail.io/docs/ai-integration/prepare-account-2-0.md

This guide gives an AI agent the rules and sequence for preparing a Lumail account for Lumail 2.0.0. The central change is simple: **marketing email belongs in Workflows, not in the transactional email API or SMTP.**

<AgentPrompt
  label="Prepare this account for Lumail 2.0.0"
  prompt="Read https://lumail.io/docs/ai-integration/prepare-account-2-0.md, then call get_skill with type workflow before changing anything. Audit the current Lumail account and the connected application. Classify every email as transactional or marketing. Keep immediate user-requested messages transactional. Move newsletters, nurture sequences, product announcements, promotions, and other marketing communication into Lumail Workflow drafts. Every marketing EMAIL step must include visible branding, a specific reason-for-sending sentence, the organization address, and an unsubscribe link. Reuse existing snippets when possible. Do not publish, activate, enroll subscribers, schedule, or send without my explicit approval. Return the audit, the drafts created or changed, and everything still requiring approval."
/>

## Non-negotiable routing rule

Classify the purpose of the message before choosing a Lumail API.

| Message           | Correct Lumail path                                  | Examples                                                                           |
| ----------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------- |
| **Transactional** | Transactional API, SDK `lumail.emails.send`, or SMTP | OTP, password reset, receipt, requested export, security alert                     |
| **Marketing**     | Lumail Workflow with one or more `EMAIL` steps       | Welcome nurture, newsletter, educational sequence, product announcement, promotion |

Do not use the transactional endpoint as a shortcut for marketing, even when the application already has the recipient's email address. Adding a delay, a promotional subject, or an unsubscribe link does not turn a transactional send into a compliant marketing workflow.

A message is transactional only when it is expected as the direct result of a recipient action and its primary purpose is to complete or confirm that action. If the primary purpose is engagement, education, retention, or sales, route it through a Workflow.

## What a prepared account contains

Before the agent reports the migration as ready, the account should have:

1. A verified sender for every Workflow email.
2. A reusable branded header or footer snippet.
3. A specific reason-for-sending line in every marketing email.
4. A physical address and unsubscribe link in every marketing email.
5. Workflow drafts for every marketing lifecycle.
6. A documented trigger for each workflow, such as subscriber created, tag added, event received, or manual enrollment.
7. Test evidence for every email and graph path.

Drafts are preparation. Publication and activation are separate, approval-gated actions.

## Phase 1: audit without sending

Start read-only. Do not create a workflow until the current state and the connected application's sends are understood.

1. Call `get_skill({ type: "docs", page: "ai-integration/prepare-account-2-0" })`.
2. Call `get_skill({ type: "workflow" })` and use the returned live schema.
3. Read `get_organization_settings`, `get_email_senders`, `get_writing_style`, and `get_email_snippets`.
4. List existing Workflows and read each relevant workflow with `get_workflow`.
5. Search the connected application for transactional API, SDK, and SMTP sends.
6. Produce an inventory with one row per email: trigger, purpose, audience, current path, required path, and migration action.

Use this decision table during the audit:

| Current email                           | Classification | Action                                             |
| --------------------------------------- | -------------- | -------------------------------------------------- |
| Sign-in code sent after a login request | Transactional  | Keep on transactional send                         |
| Receipt sent immediately after payment  | Transactional  | Keep on transactional send                         |
| Welcome education after signup          | Marketing      | Add subscriber/tag, then enter a Workflow          |
| Trial tips over several days            | Marketing      | Build a timed Workflow                             |
| Product launch announcement             | Marketing      | Use a Workflow and an eligible subscribed audience |
| “We miss you” re-engagement             | Marketing      | Use a Workflow with clear exit conditions          |

If the purpose is mixed, treat the message as marketing or split it into one necessary transactional message and a separate marketing workflow.

## Phase 2: create reusable email identity

Read existing snippets before creating another one. Prefer one shared header and one shared compliance footer so all Workflow emails stay consistent.

### Clear branding

Every marketing email must make the sender identifiable in a few seconds. Include at least one visible identifier:

- Organization or product name
- Recognizable logo with useful alt text
- Website domain displayed as text

Put branding near the top when possible. A compact footer is acceptable for personal, text-first email, but the identity must remain readable. Follow the complete [Clear Branding guide](/docs/tutorials/clear-branding).

### Clear reason for sending

Every marketing email must explain the real action that placed the subscriber in this audience:

> You're receiving this email because you subscribed to the Acme newsletter at acme.com.

For several legitimate sources, list the real possibilities:

> You're receiving this email because you subscribed through acme.com, completed one of our quizzes, or joined an Acme program.

Do not use circular wording such as “because you subscribed to my emails” or “for marketing purposes.” Follow the complete [Reason for Sending guide](/docs/tutorials/reason-for-sending).

### Required marketing footer

The reusable footer must contain all three:

1. The reason-for-sending sentence.
2. The organization address with `{{org-adresse}}`.
3. A visible unsubscribe link using `{{unsubscribeUrl}}`.

Use `get_email_snippets` and `get_email_snippet` before `create_email_snippet` or `update_email_snippet`. Updating a shared snippet changes every email that uses it, so report the affected snippets and Workflows before saving.

## Phase 3: move marketing into Workflow drafts

For each marketing lifecycle:

1. Define the entry event and eligible subscriber status.
2. Reuse an existing draft or call `create_workflow` with a descriptive name.
3. Read the new or existing workflow with `get_workflow` and keep its exact `updatedAt` value.
4. Build the complete graph with triggers, waits, `EMAIL` steps, branches, goals, exits, and edges.
5. Put complete inline email content on each `EMAIL` step, including branding and the required footer.
6. Call `configure_workflow_draft` once with the complete graph and `expectedUpdatedAt`.
7. Read the workflow again and verify the saved graph and every owned email.

`configure_workflow_draft` is the safe preparation tool: it validates and saves the draft atomically, but it never publishes, activates, enrolls, or sends.

Do not call `create_campaign` for a Workflow email. Each `EMAIL` step owns its internal email content.

## Phase 4: connect the application

The application should provide subscriber state and events, not send the marketing email itself.

Typical integration flow:

```text
User action in the app
        ↓
Create or update the Lumail subscriber
        ↓
Add the trigger tag or track the expected event
        ↓
Lumail Workflow enrolls the eligible subscriber
        ↓
Workflow waits, branches, sends, and exits
```

Public signups should respect double opt-in. With double opt-in enabled, subscriber-created and tag-added workflows wait until the subscriber confirms. Never put `skipDoubleOptIn: true` on a public or client-side form.

Keep **Add transactional recipients to the marketing list** off unless the organization has a valid marketing-consent reason. Receiving an OTP or receipt does not make someone a marketing subscriber.

## Phase 5: test and stop for approval

Before requesting publication:

1. Validate every graph path and branch.
2. Run a dry test with a test subscriber.
3. Send test copies of every Workflow email only to the approved test address.
4. Check mobile rendering, links, variables, dark mode, sender, branding, reason for sending, address, and unsubscribe.
5. Confirm **Branding** is `5/5` and **Reason for sending** is `10/10` in Content checks.
6. Read the Workflow again and report its status as `DRAFT`.

Then stop. `publish_workflow`, activation, enrollment, scheduling, and real sending require explicit human approval. Never infer approval from “prepare,” “migrate,” “configure,” or “make it ready.”

## Agent completion report

Return a concise report with:

- Transactional sends kept in the application
- Marketing sends removed or queued for removal from transactional code
- Workflow drafts created or updated
- Trigger and audience for each Workflow
- Shared snippets created or reused
- Branding and reason-for-sending evidence for every email
- Test results and unresolved validation errors
- Exact actions still awaiting approval

Do not report the account as migrated if marketing remains on `lumail.emails.send`, `/api/v2/emails`, `/api/v1/emails`, or SMTP.

## Related documentation

- [Migration to high deliverability emails](/docs/tutorials/migration-to-high-deliverability-emails)
- [Clear Branding](/docs/tutorials/clear-branding)
- [Reason for Sending](/docs/tutorials/reason-for-sending)
- [Workflow Getting Started](/docs/workflows/workflow-getting-started)
- [Workflow Email Steps](/docs/workflows/workflow-email-step)
- [Publishing and Versions](/docs/workflows/workflow-publishing)
- [Tools API](/docs/ai-integration/tools-api)

---

# Claude Code (Plugin)
Source: https://lumail.io/docs/ai-integration/mcp-claude.md

Install the Lumail plugin in Claude Code and it can read your subscribers, campaigns, workflows and analytics, and draft new ones. Authentication is a browser sign-in: there is no API token to create, paste, or rotate.

## Setup: send this to your agent

Paste this into Claude Code and it does the whole setup for you — marketplace, plugin, browser login, verification.

<AgentPrompt
  label="Prompt"
  prompt="Read lumail.io/claude-code/install and install the Lumail plugin for me."
/>

Your agent fetches the machine-readable guide at [lumail.io/claude-code/install](https://lumail.io/claude-code/install), runs the commands, and tells you when to approve the browser prompt.

> **Administrator access required:** you must be an owner or admin of the organization you connect. Losing that role blocks the connection immediately.

## Setup: by hand

Claude Code 2.x or newer is required for plugin marketplaces.

```bash
claude plugin marketplace add https://github.com/Melvynx/lumail-skills.git
claude plugin install lumail@lumail
claude mcp login plugin:lumail:lumail
```

The third command opens a Lumail tab in your browser. Sign in, pick the organization the agent may reach, and approve the scopes.

Verify:

```bash
claude mcp get plugin:lumail:lumail
```

You should see type `http`, URL `https://lumail.io/mcp`, and a connected status. `claude plugin details lumail@lumail` reports "MCP servers (0)" even on a correct install — that is a display quirk of that command, so trust `claude mcp get`.

> **Open a new conversation before your first Lumail prompt.** Claude Code reads its MCP tool list when a session starts, so the conversation that installed the plugin cannot use it.

## Why there is no token

`https://lumail.io/mcp` is an OAuth 2.1 protected resource with dynamic client registration. Claude Code registers itself, runs PKCE, and stores the access token in its own credential store. Nothing sensitive lands in your project or your dotfiles.

Access tokens carry the selected organization as a signed claim, so a session can never reach another organization in your account. Revoke from the connected-apps list in Lumail settings and access stops on the next request, with no local config to clean up.

## What the plugin can do

| Scope          | Access                                                                                                                                     |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `lumail.read`  | Subscribers, tags, custom fields, campaigns and their analytics, send progress, workflows, org stats, senders, snippets, writing style     |
| `lumail.write` | Add subscribers, apply and remove tags in bulk, queue durable bulk operations (except unsubscribe/ban), queue durable CSV exports, create and edit campaign drafts, duplicate campaigns, create and configure workflow drafts, enroll subscribers in a workflow |

Example prompts:

<AgentPrompt
  label="Try it"
  prompt={[
    "Add user@example.com to my newsletter and tag them vip.",
    "Create a draft campaign for the product launch with three subject line options.",
    "Show me subscriber growth over the last 30 days.",
    "Build a welcome sequence of three emails over a week and leave it as a draft.",
  ]}
/>

### Not available over OAuth

Sending, scheduling, publishing or activating a workflow, unsubscribing, and every delete are **not registered** on the OAuth endpoint. They are not permission prompts an agent could talk you into — the tools simply do not exist on this connection.

Do that work in the Lumail app, or from your terminal with the CLI, which reaches the full API and signs in with the same browser flow:

```bash
npx lumail auth login
```

## Rate limits

| Plan     | Requests/min |
| -------- | ------------ |
| Free     | 100          |
| Premium  | 700          |
| Business | 2,000        |

Limits are counted per organization.

## Troubleshooting

**Tools are missing on my first prompt:** open a new Claude Code conversation. The tool list is captured at session start.

**"Needs authentication":** re-run `claude mcp login plugin:lumail:lumail`.

**The login command fails with "stdin isn't a terminal":** an agent ran it through a non-interactive shell. Run it yourself in your own terminal, or follow the pseudo-TTY wrapper in the [install guide](https://lumail.io/claude-code/install).

**No organization is available:** ask an owner to promote your membership to owner or admin.

**`claude` is not on PATH:** the CLI bundled with the Claude desktop app lives under `~/Library/Application Support/Claude/claude-code/<version>/claude.app/Contents/MacOS/claude` on macOS.

## Update and removal

```bash
claude plugin marketplace update lumail
claude plugin update lumail@lumail
claude plugin uninstall lumail@lumail
```

Revoking from Lumail settings kills access immediately without touching local config.

## Related

- [Ship a product launch from Claude Code](/docs/playbooks/email-mcp-claude-code-product-launch)
- [Codex integration](/docs/ai-integration/mcp-codex)
- [ChatGPT plugin](/docs/ai-integration/chatgpt-plugin)
- [Lumail CLI skill](/docs/ai-integration/skills-cli)
- [MCP Server reference](/docs/api-reference/mcp)

For the repeatable drafting, segmentation, launch, and reporting jobs, [get the seven prompts that run a newsletter](/?prompts=1).

---

# Codex (Plugin)
Source: https://lumail.io/docs/ai-integration/mcp-codex.md

Connect Lumail to Codex and it can read your subscribers, campaigns, workflows and analytics, and draft new ones from the session where you write code. Authentication is a browser sign-in: `config.toml` holds a URL and nothing else.

## Setup: send this to your agent

Paste this into Codex and it does the whole setup for you — configuration, browser login, verification.

<AgentPrompt
  label="Prompt"
  prompt="Read lumail.io/codex/install and set up the Lumail connection for me."
/>

Your agent fetches the machine-readable guide at [lumail.io/codex/install](https://lumail.io/codex/install), writes the config block, and tells you when to approve the browser prompt.

> **Administrator access required:** you must be an owner or admin of the organization you connect. Losing that role blocks the connection immediately.

## Setup: by hand

Add this to `~/.codex/config.toml`:

```toml
[mcp_servers.lumail]
url = "https://lumail.io/mcp"
oauth_resource = "https://lumail.io/mcp"
```

Then sign in:

```bash
codex mcp login lumail
```

A Lumail tab opens in your browser. Sign in, pick the organization the agent may reach, and approve the scopes. Verify with:

```bash
codex mcp list
```

`lumail` should appear at `https://lumail.io/mcp`, connected. Inside the Codex TUI, `/mcp` shows the same. Start a new session before your first Lumail prompt so the tool list is picked up.

> **Migrating from an API token?** Delete the `bearer_token_env_var` line from your `[mcp_servers.lumail]` block. A stale bearer token shadows the OAuth credential and keeps you on the old endpoint.

If your Codex build supports plugin marketplaces, you can install the packaged plugin instead and skip straight to the login:

```bash
codex plugin marketplace add https://github.com/Melvynx/lumail-skills.git
codex plugin install lumail
```

## Why there is no token

`https://lumail.io/mcp` is an OAuth 2.1 protected resource with dynamic client registration. Codex registers itself, runs PKCE, and stores the access token in its own credential store — so committing your dotfiles leaks nothing.

Access tokens carry the selected organization as a signed claim, so a session can never reach another organization in your account. Revoke from the connected-apps list in Lumail settings and access stops on the next request.

## What the connection can do

| Scope          | Access                                                                                                                                     |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `lumail.read`  | Subscribers, tags, custom fields, campaigns and their analytics, send progress, workflows, org stats, senders, snippets, writing style     |
| `lumail.write` | Add subscribers, apply and remove tags in bulk, create and edit campaign drafts, duplicate campaigns, create and configure workflow drafts, enroll subscribers in a workflow |

Example prompts:

<AgentPrompt
  label="Try it"
  prompt={[
    "How did last week's newsletter perform compared to the one before?",
    "Add every trial signup from this CSV and tag them trial-2026.",
    "Draft a re-engagement campaign for subscribers who have not opened in 60 days.",
    "Create a workflow that welcomes new subscribers over five days.",
  ]}
/>

### Not available over OAuth

Sending, scheduling, publishing or activating a workflow, unsubscribing, and every delete are **not registered** on the OAuth endpoint. They are not permission prompts an agent could talk you into — the tools simply do not exist on this connection.

Do that work in the Lumail app, or from your terminal with the CLI, which reaches the full API and signs in with the same browser flow:

```bash
npx lumail auth login
```

## Rate limits

| Plan     | Requests/min |
| -------- | ------------ |
| Free     | 100          |
| Premium  | 700          |
| Business | 2,000        |

Limits are counted per organization.

## Troubleshooting

**OAuth settings are not discovered:** the server URL must be exactly `https://lumail.io/mcp`, without `/api` or `/sse`.

**Tools are missing:** start a new Codex session. The tool list is read at session start.

**"Needs authentication":** re-run `codex mcp login lumail`, and check that no `bearer_token_env_var` line remains in the block.

**No organization is available:** ask an owner to promote your membership to owner or admin.

## Removal

Delete the `[mcp_servers.lumail]` block from `~/.codex/config.toml`, or revoke the authorization from Lumail settings — revoking there cuts access immediately without touching local config.

## Related

- [Claude Code plugin](/docs/ai-integration/mcp-claude)
- [ChatGPT plugin](/docs/ai-integration/chatgpt-plugin)
- [Lumail CLI skill](/docs/ai-integration/skills-cli)
- [MCP Server reference](/docs/api-reference/mcp)

---

# ChatGPT Plugin
Source: https://lumail.io/docs/ai-integration/chatgpt-plugin.md

Connect Lumail as a custom plugin in ChatGPT. The connection uses OAuth, so you sign in with your existing Lumail account, choose an organization, and approve access without creating an API token.

> **Administrator access required:** You must be an owner or admin of the Lumail organization you connect. Removing that role immediately prevents the plugin from accessing the organization.

Using the Codex CLI or the Codex app instead? Paste `Read lumail.io/codex/install and set up the Lumail connection for me.` into Codex and it configures itself — see the [Codex guide](/docs/ai-integration/mcp-codex).

## Add the Plugin

ChatGPT's plugin settings are a UI flow, so this one is done by hand.

1. Open **ChatGPT Settings → Security and login** and enable **Developer mode**.
2. Open **Plugins** from the ChatGPT sidebar, then click **Create app**.
3. Enter the following values:

| Setting            | Value                                                                  |
| ------------------ | ---------------------------------------------------------------------- |
| **Name**           | `Lumail`                                                               |
| **Description**    | `Draft campaigns, manage subscribers, and analyze Lumail with ChatGPT` |
| **Connection**     | **Server URL**                                                         |
| **Server URL**     | `https://lumail.io/mcp`                                                |
| **Authentication** | **OAuth**                                                              |

4. Review and accept the custom MCP server warning, then click **Create**.
5. Sign in to Lumail, select the organization to connect, and approve the requested access.

Lumail publishes the OAuth discovery metadata automatically. You do not need a client ID, client secret, or manually configured authorization URL.

## What the Plugin Can Do

The plugin requests three Lumail scopes:

| Scope          | Access                                                                                                                              |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `lumail.read`  | Read subscribers, tags, campaigns, analytics, Workflow drafts, senders, fields, and style                                           |
| `lumail.write` | Add subscribers and tags, create or edit campaign and Workflow drafts, and enroll subscribers in a workflow                         |
| `lumail.full`  | Access every MCP tool, including sending emails, scheduling campaigns, publishing workflows, deleting records, and managing domains |

Examples:

<AgentPrompt
  label="Try it"
  prompt={[
    "Show me the performance of my last five campaigns.",
    "Create a draft campaign for our product launch, but do not send it.",
    'Add customer@example.com with the tag "early-access".',
    "Build a Workflow welcome sequence and leave it as a draft.",
  ]}
/>

The OAuth plugin exposes the same complete tool catalog as the API-token MCP endpoint. Tools outside the original read/write catalog require `lumail.full`; existing connections must authorize that additional permission before using them. Sensitive actions retain Lumail's confirmation-code flow, and organization limits and sending eligibility checks still apply.

## Security and Revocation

- Access tokens are scoped to the organization selected during authorization.
- Only owners and admins can connect an organization.
- Lumail checks the user's current organization membership on every MCP request.
- Disconnect the plugin from ChatGPT to remove the connection. Removing the user's owner/admin role blocks access immediately.

## Troubleshooting

**OAuth settings are not discovered:** Confirm that the server URL is exactly `https://lumail.io/mcp`, without `/api` or `/sse`.

**No organization is available:** Ask an organization owner to promote your Lumail membership to owner or admin.

**The connection stopped working:** Your Lumail session can be refreshed automatically, but a removed membership or downgraded role requires an owner to restore access.

**A tool asks for full access:** Reconnect Lumail and authorize `lumail.full` to use the complete catalog.

## Related

- [Claude Code plugin](/docs/ai-integration/mcp-claude)
- [Codex plugin](/docs/ai-integration/mcp-codex)
- [MCP Server reference](/docs/api-reference/mcp)
- [API Tokens](/docs/api-reference/api-tokens)

---

# Hermes
Source: https://lumail.io/docs/ai-integration/hermes.md

Hermes Agent can use Lumail through its native skills system. You don't need to run any command yourself — paste this prompt and Hermes sets everything up:

<AgentPrompt
  label="Prompt"
  emphasis
  prompt="Read lumail.io/cli/install and set up Lumail for me."
/>

Hermes fetches the machine-readable guide at [lumail.io/cli/install](https://lumail.io/cli/install), installs the CLI, walks you through the browser sign-in, installs the `lumail` skill, and verifies the connection with a read command before reporting back.

## Setup: by hand

Prefer to run the steps yourself? Install the skill from its open-source `SKILL.md`:

```bash
hermes skills install https://raw.githubusercontent.com/Melvynx/lumail-skills/main/skills/lumail/SKILL.md
```

Hermes discovers the skill automatically and makes it available as `/lumail`.

Then install the CLI and set your API token from your Lumail organization settings:

```bash
npm install -g lumail
lumail auth set lum_your_api_token_here
```

Verify the connection:

```bash
lumail auth test
```

## Try It

Once connected, just ask Hermes naturally:

<AgentPrompt
  label="Try it"
  prompt={[
    "Use Lumail to show me my draft campaigns.",
    "Create a campaign draft for our product launch, but do not send it.",
    "Show me subscriber growth over the last 30 days.",
  ]}
/>

Hermes runs the corresponding Lumail CLI commands in your terminal, so you can review each action and its output.

## What's Included

The skill covers subscribers, campaigns, tags, transactional email, events, analytics, and the organization-aware Lumail tools. Commands that send, schedule, delete, or unsubscribe affect real data, so review them before approval.

## Related

- [Skills (CLI)](/docs/ai-integration/skills-cli) - Learn how the Lumail skill works
- [CLI Reference](/docs/api-reference/cli) - Explore every CLI command
- [OpenClaw](/docs/ai-integration/openclaw) - Install Lumail through ClawHub

---

# Skills (CLI)
Source: https://lumail.io/docs/ai-integration/skills-cli.md

Claude Code skills give Claude deep knowledge of the Lumail CLI, so it can run commands on your behalf directly from conversation. Unlike MCP which exposes raw tools, skills teach Claude _how_ to use the CLI effectively.

## Setup: send this to your agent

Paste this into your agent — Claude Code, Codex, or any agent with a terminal — and it does the whole setup for you: CLI install, browser sign-in, skill, verification.

<AgentPrompt
  label="Prompt"
  prompt="Read lumail.io/cli/install and set up the Lumail CLI for me."
/>

Your agent fetches the machine-readable guide at [lumail.io/cli/install](https://lumail.io/cli/install), installs the CLI, walks you through the browser sign-in, and drops the `lumail` skill into its skills directory.

## Setup: by hand

Install the CLI and sign in through your browser — no API token to create:

```bash
npm install -g lumail
lumail auth login
lumail auth test
```

## Automatic Setup (Legacy)

```bash
pnpm dlx lumail setup
```

Choose **Skills (CLI)** to install the skill and save your Lumail API token, or choose **Both** to configure MCP and the skill in the same guided flow.

The command downloads the Lumail skill to `~/.claude/skills/lumail/` so Claude Code knows every CLI command and SDK method.

## Manual Setup

Install the skill with the Skills CLI:

```bash
pnpm dlx skills add Melvynx/lumail-skills -g
```

Then install the CLI and set your token:

```bash
npm install -g lumail
lumail auth set lum_your_api_token_here
```

If you prefer to download the skill directly:

```bash
# Download the skill manually
curl -o ~/.claude/skills/lumail/SKILL.md --create-dirs \
  https://raw.githubusercontent.com/Melvynx/lumail-skills/main/skills/lumail/SKILL.md
```

## What Can Claude Do With Skills?

Once the skill is installed, Claude knows every CLI command and SDK method. Just ask naturally:

```
"Add user@example.com to my newsletter with the vip tag"
→ Claude runs: lumail subscribers create --email user@example.com --tags vip

"Show me all draft campaigns"
→ Claude runs: lumail campaigns list --status DRAFT

"Send the Welcome campaign now"
→ Claude runs: lumail campaigns send <id>

"Export my subscribers as CSV"
→ Claude runs: lumail tools run list_subscribers --params '{"limit":1000}' --format csv
```

## Skills vs MCP

|                  | Skills (CLI)                              | MCP                                  |
| ---------------- | ----------------------------------------- | ------------------------------------ |
| **How it works** | Claude runs CLI commands in your terminal | Claude calls tools via HTTP protocol |
| **Setup**        | `pnpm dlx lumail setup` + choose Skills   | `pnpm dlx lumail setup` + choose MCP |
| **Works in**     | Claude Code                               | Claude Code, Claude Desktop, Cursor  |
| **Output**       | Visible in terminal (text, JSON, CSV)     | Returned as tool results             |
| **Offline**      | Yes (after install)                       | No (requires API connection)         |
| **Best for**     | Terminal workflows, scripting, piping     | IDE integration, desktop assistants  |

Both methods give Claude access to the same capabilities. Skills are better when you want to see and control what's happening in your terminal. MCP is better for seamless IDE integration.

## Available CLI Commands

The skill teaches Claude all of these:

```bash
lumail subscribers create --email x --name y --tags a b
lumail subscribers get <email>
lumail subscribers update <email> --name y
lumail subscribers unsubscribe <email>
lumail subscribers add-tags <email> --tags a b
lumail subscribers remove-tags <email> --tags a
lumail subscribers events <email>

lumail campaigns list --status DRAFT
lumail campaigns create --subject "Hello" --name "My Campaign"
lumail campaigns get <id>
lumail campaigns update <id> --subject "Updated"
lumail campaigns delete <id>
lumail campaigns send <id>

lumail tags list / create / get / update
lumail emails send --to x --from y --subject z --content "..."
lumail emails verify <email>
lumail events create --type SUBSCRIBED --subscriber <email>
lumail tools list / get / run
```

All commands support `--json`, `--format csv`, `--no-header` for scripting.

## Open Source

The skill file is open source at [github.com/Melvynx/lumail-skills](https://github.com/Melvynx/lumail-skills). Contributions welcome.

## Related

- [Claude (MCP)](/docs/ai-integration/mcp-claude) - Alternative: MCP integration
- [CLI Reference](/docs/api-reference/cli) - Full CLI documentation
- [TypeScript SDK](/docs/api-reference/sdk) - Programmatic usage

---

# OpenClaw
Source: https://lumail.io/docs/ai-integration/openclaw.md

## Install the Skill

Install the Lumail skill from [ClawHub](https://clawhub.ai/melvynx/lumail):

```bash
npx clawhub@latest install melvynx/lumail --no-input
```

This downloads the Lumail skill so your AI agent knows every CLI command available.

## Install the CLI

```bash
npm install -g lumail
```

Then set your API token:

```bash
lumail auth set YOUR_LUMAIL_TOKEN
```

Replace `YOUR_LUMAIL_TOKEN` with your API token from [Settings > API Tokens](https://lumail.io/orgs).

## Verify

Run this to confirm everything works:

```bash
lumail auth test
```

## What's Included

The skill teaches your AI agent the current Lumail CLI and tool catalog for subscribers, campaigns, Workflows, tags, emails, and events. Just ask naturally and the agent will run the right commands.

## Related

- [Skills (CLI)](/docs/ai-integration/skills-cli) - Alternative skill setup without ClawHub
- [CLI Reference](/docs/api-reference/cli) - Full CLI documentation

---

# Cursor (MCP)
Source: https://lumail.io/docs/ai-integration/mcp-cursor.md

Add Lumail's current organization-aware tool catalog to Cursor so you can manage email marketing without leaving your editor.

## Setup: send this to your agent

Paste this into Cursor's agent and it does the setup for you — configuration, sign-in, verification.

<AgentPrompt
  label="Prompt"
  prompt="Read lumail.io/mcp/install and connect this MCP client to the correct Lumail organization. In Cursor, prefer one organization in a project-level .cursor/mcp.json and never register the same HTTP endpoint twice in one scope."
/>

Your agent fetches the machine-readable guide at [lumail.io/mcp/install](https://lumail.io/mcp/install), picks the connection style your Cursor version supports (OAuth when available, API token otherwise), writes the MCP configuration, and verifies the tool list.

## Setup

### 1. Get Your API Token

Go to **Settings > API Tokens** in your Lumail dashboard and generate a token.

### 2. Add MCP Server

Open Cursor Settings > MCP Servers and add:

```json
{
  "mcpServers": {
    "lumail": {
      "url": "https://lumail.io/api/mcp/sse",
      "headers": {
        "Authorization": "Bearer lum_your_api_token_here"
      }
    }
  }
}
```

If your Cursor version doesn't support HTTP MCP, use the stdio bridge:

```json
{
  "mcpServers": {
    "lumail": {
      "command": "npx",
      "args": [
        "-y",
        "mcp-remote",
        "https://lumail.io/api/mcp/sse",
        "--header",
        "Authorization: Bearer lum_your_api_token_here"
      ]
    }
  }
}
```

### Recommended: one organization per project

Each MCP authorization is intentionally scoped to exactly one Lumail
organization. Put one `lumail` entry in each project's `.cursor/mcp.json` so the
repository and organization stay aligned.

Project-level configuration does **not** mean Lumail must run on localhost. A
normal project-level entry still targets `https://lumail.io/mcp`; "project
level" only describes where Cursor stores the configuration.

Do not commit an organization API token. Prefer OAuth, or keep any token-backed
project configuration out of version control.

### Multiple Lumail organizations

Do not register the same remote HTTP endpoint twice in one Cursor scope. Cursor
can collapse same-origin MCP entries even when their names or credentials
differ, leaving only one organization's tools available. This configuration is
therefore not reliable:

```json
{
  "mcpServers": {
    "lumail-main": {
      "url": "https://lumail.io/mcp"
    },
    "lumail-thumbfast": {
      "url": "https://lumail.io/mcp"
    }
  }
}
```

Use separate Cursor projects or profiles for separate organizations. If one
task must deliberately span organizations, use named Lumail CLI accounts and
pass `--account <name>` on each command. That keeps the organization explicit on
every operation instead of relying on hidden account-switching state.

### Local Lumail development

When developing Lumail itself, point the project entry at the Portly-managed
local server:

```json
{
  "mcpServers": {
    "lumail-local": {
      "url": "http://localhost:3002/api/mcp/sse",
      "headers": {
        "Authorization": "Bearer lum_your_api_token_here"
      }
    }
  }
}
```

Keep one organization per project and never commit the token. Localhost is a
development option for Lumail contributors, not the default customer setup. If
you are explicitly testing two local accounts in one Cursor profile, Cursor
needs distinct loopback origins such as `localhost` and `127.0.0.1`; unique
server names alone are not sufficient.

### 3. Use in Cursor Chat

Ask Cursor to interact with Lumail:

```
"Add all the emails from this CSV to Lumail with the tag 'import-march'"

"Create a campaign with the content from this markdown file"

"Check the open rate of our last campaign"
```

## Use Cases

- **Import subscribers** from code - ask Cursor to read a file and add contacts
- **Draft campaigns** - write content in your editor, then send via Cursor
- **Debug deliverability** - check bounce rates and email verification from your IDE
- **Automate workflows** - create email sequences while building features

Workflow automation uses the canonical graph tools such as `get_workflow` and `configure_workflow_draft`. Read the live MCP schema instead of using version-suffixed or step-by-step legacy names.

## Related

- [Send a newsletter from Cursor](/docs/playbooks/send-newsletter-from-cursor)
- [Claude Integration](/docs/ai-integration/mcp-claude)
- [Full MCP Reference](/docs/api-reference/mcp)
- [API Tokens](/docs/api-reference/api-tokens)

For the repeatable drafting, segmentation, launch, and reporting jobs, [get the seven prompts that run a newsletter](/?prompts=1).

---

# TypeScript SDK Integrations
Source: https://lumail.io/docs/ai-integration/sdk-agents.md

The Lumail TypeScript SDK is not an AI-agent framework. It is a typed wrapper around the Lumail API that makes common email marketing operations easier to integrate in your own application.

Use it when you want to create subscribers from signup flows, send transactional email from your backend, trigger campaigns from product events, or call Lumail tools from server-side code.

## When to Use the SDK

| Use case                                          | Recommended                                                     |
| ------------------------------------------------- | --------------------------------------------------------------- |
| Web app or SaaS backend integration               | Yes                                                             |
| Checkout, onboarding, or product-event automation | Yes                                                             |
| Serverless functions and internal scripts         | Yes                                                             |
| Claude, Cursor, or another MCP client             | Use [MCP](/docs/ai-integration/mcp-claude) instead              |
| Any language other than TypeScript or JavaScript  | Use the [Tools API](/docs/ai-integration/tools-api) or REST API |

## Install

```bash
pnpm add lumail
```

## Web App Integration Pattern

```typescript
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY! });

export async function registerLead(input: {
  email: string;
  name?: string;
  plan: string;
}) {
  const { subscriber } = await lumail.subscribers.create({
    email: input.email,
    name: input.name,
    tags: ["product-signup", input.plan],
    fields: {
      plan: input.plan,
      signupDate: new Date().toISOString(),
    },
    triggerWorkflows: true,
  });

  await lumail.events.create({
    eventType: "SUBSCRIBED",
    subscriber: input.email,
    data: { plan: input.plan, source: "app-signup" },
  });

  return subscriber;
}
```

## Backend Automation Pattern

```typescript
import { Lumail, LumailNotFoundError, LumailRateLimitError } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY! });

export async function syncCustomerProfile(
  email: string,
  fields: Record<string, string>,
) {
  try {
    return await lumail.subscribers.update(email, { fields });
  } catch (error) {
    if (error instanceof LumailNotFoundError) {
      return lumail.subscribers.create({ email, fields });
    }

    if (error instanceof LumailRateLimitError) {
      const delay = error.retryAfter ?? 5000;
      await new Promise((resolve) => setTimeout(resolve, delay));
      return syncCustomerProfile(email, fields);
    }

    throw error;
  }
}
```

## Campaign Workflow Pattern

```typescript
export async function scheduleWeeklyDigest(scheduledAt: Date) {
  const { campaignId } = await lumail.campaigns.create({
    subject: `Weekly digest - ${scheduledAt.toLocaleDateString()}`,
    name: "Weekly Digest",
    contentType: "MARKDOWN",
  });

  await lumail.campaigns.send(campaignId, {
    scheduledAt: scheduledAt.toISOString(),
    timezone: "Europe/Paris",
  });

  return campaignId;
}
```

## Tools from Your App

For operations that are exposed as Lumail tools, call the V2 tools API through the SDK:

```typescript
const stats = await lumail.tools.run("get_dashboard_stats", {
  period: "30d",
});

const { tools } = await lumail.tools.list();
console.log(`${tools.length} tools available`);
```

These calls are still regular API integrations. If you want an AI assistant to discover and call tools directly, use [MCP](/docs/ai-integration/mcp-claude) or the [Tools API](/docs/ai-integration/tools-api).

## Related

- [SDK Introduction](/docs/sdk/introduction)
- [SDK Configuration](/docs/sdk/configuration)
- [SDK Resources](/docs/sdk/resources)
- [Tools API with SDK](/docs/sdk/tools)
- [TypeScript SDK Reference](/docs/api-reference/sdk)

---

# Tools API for AI Agents
Source: https://lumail.io/docs/ai-integration/tools-api.md

The Tools API exposes Lumail's organization-aware tool catalog through a single REST pattern. Workflow tools are available to every organization. It works with any language, framework, or AI agent that can make HTTP requests.

## Quick Start

```bash
# List all tools
curl https://lumail.io/api/v2/tools \
  -H "Authorization: Bearer lum_your_token"

# Run a tool
curl -X POST https://lumail.io/api/v2/tools/list_subscribers \
  -H "Authorization: Bearer lum_your_token" \
  -H "Content-Type: application/json" \
  -d '{"limit": 10, "status": "SUBSCRIBED"}'
```

## Pattern

All tools follow the same URL pattern:

```
POST https://lumail.io/api/v2/tools/{tool_name}
```

With request body containing the tool's parameters as JSON.

## Response Format

```json
{
  "success": true,
  "data": { ... }
}
```

## Using with OpenAI Function Calling

```python
import openai
import requests

LUMAIL_TOKEN = "lum_..."

# Define Lumail tools as OpenAI functions
tools = [{
    "type": "function",
    "function": {
        "name": "add_subscriber",
        "description": "Add a subscriber to Lumail",
        "parameters": {
            "type": "object",
            "properties": {
                "email": {"type": "string"},
                "name": {"type": "string"},
                "tags": {"type": "array", "items": {"type": "string"}}
            },
            "required": ["email"]
        }
    }
}]

# When OpenAI calls the function, forward to Lumail
def handle_tool_call(name, args):
    response = requests.post(
        f"https://lumail.io/api/v2/tools/{name}",
        headers={"Authorization": f"Bearer {LUMAIL_TOKEN}"},
        json=args,
    )
    return response.json()
```

## Using with LangChain

```typescript
import { DynamicTool } from "langchain/tools";

const lumailTool = new DynamicTool({
  name: "lumail_add_subscriber",
  description: "Add a subscriber to the email list",
  func: async (input: string) => {
    const params = JSON.parse(input);
    const res = await fetch("https://lumail.io/api/v2/tools/add_subscriber", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.LUMAIL_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(params),
    });
    return JSON.stringify(await res.json());
  },
});
```

## Tool Categories

| Category    | Tools                                                                                                                                      |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| Subscribers | `list_subscribers`, `add_subscriber`, `get_subscriber`, `update_subscriber`, `unsubscribe`, `add_tags`, `remove_tags` (`delete_subscriber` exists but always returns an error; use unsubscribe or dashboard lawful erasure) |
| Campaigns   | `list_campaigns`, `create_campaign`, `get_campaign`, `edit_campaign`, `send_campaign`, `delete_campaign`                                   |
| Tags        | `list_tags`, `create_tag`, `get_tag`, `update_tag`                                                                                         |
| Fields      | `get_custom_fields`, `create_custom_field`, `rename_custom_field`, `delete_custom_field`                                                    |
| Emails      | `send_email`, `verify_email`                                                                                                               |
| Events      | `track_event`, `list_events`                                                                                                               |
| Workflows   | Complete Workflow graph, lifecycle, enrollment, and group-management tools                                                                 |
| Analytics   | `get_dashboard_stats`, `get_campaign_stats`                                                                                                |

## Workflow

Workflow tools expose the canonical graph, lifecycle, enrollment, and group APIs for `/workflows/` resources.

### Complete tool set

| Area        | Tools                                                                                                                                         |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| Read/create | `list_workflows`, `get_workflow`, `create_workflow`                                                                                           |
| Draft graph | `configure_workflow_draft`, `update_workflow_draft`                                                                                           |
| Lifecycle   | `publish_workflow`, `update_workflow_status`, `delete_workflow`                                                                               |
| Enrollment  | `add_subscriber_to_workflow`, `add_subscribers_to_workflow`, `remove_subscriber_from_workflow`                                                |
| Runs        | `get_subscriber_workflow_runs`, `fast_forward_workflow_subscriber`                                                                            |
| Groups      | `list_workflow_groups`, `get_workflow_group`, `create_workflow_group`, `update_workflow_group`, `set_workflow_group`, `delete_workflow_group` |

Read schemas dynamically instead of hard-coding them:

```bash
curl https://lumail.io/api/v2/tools/configure_workflow_draft \
  -H "Authorization: Bearer lum_your_token"

curl https://lumail.io/api/v2/tools/create_workflow_group \
  -H "Authorization: Bearer lum_your_token"
```

For complete graph work, call `get_skill` with `{ "type": "workflow" }`, read the workflow, and pass its exact `updatedAt` as `expectedUpdatedAt`. `configure_workflow_draft` replaces the full graph atomically but never publishes or sends.

For groups, list and read before mutation. Group updates and workflow assignment use optimistic concurrency. Group deletion is confirmation-gated and refuses assigned workflows unless `unassignWorkflows: true` is explicit.

See [Workflow](/docs/workflows/workflow) for every event, step, edge, setting, status, goal, exit, and verification rule.

## Rate Limits

| Plan     | Requests/min |
| -------- | ------------ |
| Free     | 100          |
| Premium  | 700          |
| Business | 2,000        |

## Related

- [Full V2 Tools Reference](/docs/api-reference/v2-tools)
- [TypeScript SDK](/docs/api-reference/sdk)
- [MCP Server](/docs/api-reference/mcp)

---

# Tools reference
Source: https://lumail.io/docs/ai-integration/tools-reference.md

> Generated from the tool registry by `pnpm tools:reference`. Do not edit this page by hand.

Lumail exposes 139 organization-scoped tools. The same definitions power the in-app assistant, the MCP server, the ChatGPT / Claude plugin and the [Tools API](/docs/ai-integration/tools-api) (`POST /api/v2/tools/{name}`). `GET /api/v2/tools` returns the full JSON Schema of every tool your token can call.

## Campaigns

### list_campaigns

List email campaigns in your organization with optional filtering by status and search.

- **Endpoint:** `POST /api/v2/tools/list_campaigns`
- **Token permission:** `campaigns`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `limit` | `integer` | No | Default: `20`. |
| `page` | `integer` | No | Default: `1`. |
| `after` | `string` | No |  |
| `before` | `string` | No |  |
| `status` | `"DRAFT" \| "SCHEDULED" \| "SENDING" \| "SENT" \| "ARCHIVED" \| "FAILED"` | No |  |
| `query` | `string` | No |  |

### create_campaign

Create a new email campaign. The campaign will be created in DRAFT status. If no senderId is provided, it will use the default sender, then last used sender, then any available sender.

- **Endpoint:** `POST /api/v2/tools/create_campaign`
- **Token permission:** `campaigns`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `subject` | `string` | Yes | Email subject line (required) |
| `name` | `string` | No | Campaign name. Default: `"Untitled"`. |
| `preview` | `string \| null` | No | Preview/preheader text shown in email clients |
| `content` | `any` | No | MAILY: TipTap JSON ({ type: "doc", content: [...] }). HTML: a raw HTML string that already includes &lt;a href="{{unsubscribeUrl}}">Unsubscribe&lt;/a>. Rendering does not add a footer. Call get_skill({ type: "campaign" }) for examples. |
| `contentType` | `"MAILY" \| "HTML"` | No | Content format. MAILY is the visual editor; HTML is raw source. Default: `"MAILY"`. |
| `senderId` | `string` | No | Email sender ID. If not provided, uses default sender. Call get_email_senders to list available senders. |
| `replyTo` | `string \| null` | No | Reply-to email address |

### get_campaign

Get detailed information about a specific campaign by ID.

- **Endpoint:** `POST /api/v2/tools/get_campaign`
- **Token permission:** `campaigns`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `campaignId` | `string` | Yes |  |

### get_campaign_progress

Get the sending progress of a campaign that is currently being sent.

- **Endpoint:** `POST /api/v2/tools/get_campaign_progress`
- **Token permission:** `analytics`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `campaignId` | `string` | Yes |  |

### get_campaign_analytics

Get detailed analytics for a sent campaign including opens, clicks, and link performance.

- **Endpoint:** `POST /api/v2/tools/get_campaign_analytics`
- **Token permission:** `analytics`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `campaignId` | `string` | Yes |  |

### duplicate_campaign

Create a complete copy of a campaign including all content and settings. The duplicate will be a DRAFT.

- **Endpoint:** `POST /api/v2/tools/duplicate_campaign`
- **Token permission:** `campaigns`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `campaignId` | `string` | Yes |  |
| `newName` | `string` | No |  |

### edit_campaign

Modify a broadcast campaign (name, subject, preview, senderId, replyTo, content, contentType). Rejects workflow emails — use configure_workflow_draft.

TWO exclusive modes:
- "content": full body rewrite. MAILY = TipTap JSON ({ type: "doc", content: [...] }). HTML = a raw HTML string. Switching format requires contentType plus a matching replacement body. This replaces the old body.
- "operations": surgical TipTap edits only. Rejected on HTML campaigns.
Metadata fields (name, subject, preview, senderId, replyTo) can be combined with either mode. NEVER send content and operations together. Metadata-only updates never switch format.

Call get_campaign first. After an HTML edit, call get_campaign and render_campaign. Rendering does not add an unsubscribe footer. Missing unsubscribe is a send-time error, not a save-time error.

WHEN NOT TO USE:
- Workflow EMAIL steps (type WORKFLOW) -> configure_workflow_draft with an inline email object. Never edit_campaign. get_campaign is read-only.
- Listing/searching campaigns -> use list_campaigns
- Getting campaign details -> use get_campaign
- Updating recipient filters -> use update_campaign_filters

- **Endpoint:** `POST /api/v2/tools/edit_campaign`
- **Token permission:** `campaigns`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | Yes | Campaign ID to update |
| `name` | `string` | No | New campaign name |
| `subject` | `string` | No | New subject line |
| `preview` | `string \| null` | No | New preview/preheader text |
| `content` | `any` | No | Replacement body. MAILY: TipTap JSON. HTML: raw HTML string, e.g. &lt;p>Updated email&lt;/p>&lt;p>&lt;a href="{{unsubscribeUrl}}">Unsubscribe&lt;/a>&lt;/p>. Cannot be combined with operations. |
| `operations` | `any[]` | No | Array of surgical edit operations to apply to existing content. Cannot be combined with content. |
| `senderId` | `string` | No | Email sender ID. Call get_email_senders to list available senders. |
| `replyTo` | `string \| null` | No | Reply-to email address. Pass null to clear. |
| `contentType` | `"MAILY" \| "HTML"` | No | Target format. Required with content when switching MAILY ↔ HTML. Metadata-only updates must omit this or keep the current format. |
| `expectedUpdatedAt` | `string` | No | Revision guard from get_campaign.updatedAt. Required when switching format. |

### auto_fit_campaign_images

Apply the editor's magic-wand auto-fit to a draft campaign's images: reads each image's natural dimensions and fits it without upscaling. Always run with dryRun=true first to inspect the proposed sizes. Call get_skill({ type: "images" }) for the full sequence, sizing rules and safety limits.

- **Endpoint:** `POST /api/v2/tools/auto_fit_campaign_images`
- **Token permission:** `campaigns`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `campaignId` | `string` | Yes | Campaign or internal workflow email ID. |
| `imageIndexes` | `integer[]` | No | Optional image indexes from this tool's dry-run output. Omit to auto-fit every image in document traversal order. |
| `maxWidth` | `integer` | No | Default: `600`. |
| `maxHeight` | `integer` | No | Default: `400`. |
| `dryRun` | `boolean` | No | Inspect and calculate dimensions without changing the campaign. Default: `false`. |

### get_available_filters

Get the machine-readable filter schema (types, operators, required fields) used by update_campaign_filters, query_subscribers and segments. Prefer get_skill({ type: 'filters' }), which also documents how to resolve each ID.

- **Endpoint:** `POST /api/v2/tools/get_available_filters`
- **Token permission:** `campaigns`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

No parameters.

### update_campaign_filters

Update the recipient filters of a DRAFT campaign. Conditions in the same group use AND; groups use OR. Omitted group means 0. Call get_skill({ type: 'filters' }) first for the filter shapes, operators and how to resolve each ID.

- **Endpoint:** `POST /api/v2/tools/update_campaign_filters`
- **Token permission:** `campaigns`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `campaignId` | `string` | Yes | Campaign ID to update |
| `filters` | `object[]` | Yes | Array of filters to apply |

### send_test_email

Send a test email for a campaign to one or more email addresses (max 50). The test email will have '[Test]' prefix in the subject. Test emails are not tracked in analytics.

- **Endpoint:** `POST /api/v2/tools/send_test_email`
- **Token permission:** `campaigns`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `campaignId` | `string` | Yes | Campaign ID to send test for |
| `emails` | `string[]` | Yes | Email addresses to send test to (1-50) |

### render_campaign

Render a campaign to its final HTML and plain text. Nothing is sent. Rendering does not add an unsubscribe footer — hasUnsubscribeLink reports whether a usable link is already present. Pass subscriberId to resolve variables and Show if conditions; otherwise all conditional blocks are shown.

- **Endpoint:** `POST /api/v2/tools/render_campaign`
- **Token permission:** `campaigns`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `campaignId` | `string` | Yes | Campaign ID to render |
| `subscriberId` | `string` | No | Optional subscriber ID to resolve variables and conditional blocks for |
| `format` | `"html" \| "text" \| "both"` | No | Which output to return (default: both) |

### schedule_campaign

Schedule a DRAFT campaign (status becomes SCHEDULED). Campaign AI findings and evaluator outages do not block HUMAN_VERIFIED, WHITELISTED, or organizations retaining prior human approval. AUTO_VERIFIED and AI_VERIFIED without prior human approval remain subject to blocking checks. The server determines eligibility; other sending restrictions and the unsubscribe link requirement still apply. No date sends immediately; a date without time sends at midnight in the given timezone.

**This is a dangerous action requiring an explicit second request. The first call returns a short-lived, one-use confirmation code bound to the organization, token, tool, and exact input.**

- **Endpoint:** `POST /api/v2/tools/schedule_campaign`
- **Token permission:** `send`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `campaignId` | `string` | Yes | Campaign ID to schedule |
| `date` | `string` | No | Date to schedule (ISO format, e.g. '2025-03-20'). If omitted, sends immediately. |
| `hours` | `integer` | No | Hour to send (0-23) |
| `minutes` | `integer` | No | Minutes (0-59) |
| `timezone` | `string \| null` | No | Timezone for the scheduled time (e.g. 'Europe/Paris', 'America/New_York') |
| `confirmationCode` | `number` | No | Required for dangerous actions. Include the code from the confirmation challenge to execute. |

### cancel_campaign_schedule

Cancel a scheduled campaign, reverting it back to DRAFT status. Only works on SCHEDULED campaigns (not SENDING or SENT).

**This is a dangerous action requiring an explicit second request. The first call returns a short-lived, one-use confirmation code bound to the organization, token, tool, and exact input.**

- **Endpoint:** `POST /api/v2/tools/cancel_campaign_schedule`
- **Token permission:** `send`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `campaignId` | `string` | Yes | Campaign ID to cancel schedule for |
| `confirmationCode` | `number` | No | Required for dangerous actions. Include the code from the confirmation challenge to execute. |

### list_campaign_history

List version history entries for a campaign. Returns entries sorted by most recent first with cursor-based pagination. Each entry shows when it was saved and the source (autosave, manual, or ai).

- **Endpoint:** `POST /api/v2/tools/list_campaign_history`
- **Token permission:** `campaigns`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `campaignId` | `string` | Yes | Campaign ID to get history for |
| `cursor` | `string` | No | Cursor for pagination (history entry ID from previous page) |
| `limit` | `integer` | No | Number of entries to return (default 20, max 100) Default: `20`. |

### get_campaign_history_entry

Get a specific campaign history entry with full content. Use this to inspect what the campaign looked like at a specific point in time before restoring it.

- **Endpoint:** `POST /api/v2/tools/get_campaign_history_entry`
- **Token permission:** `campaigns`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `campaignId` | `string` | Yes | Campaign ID |
| `historyId` | `string` | Yes | History entry ID to retrieve |

### restore_campaign_history

Restore a campaign to a previous version from its history. This updates the campaign's subject and content to match the selected history entry, and creates a new history entry recording the restoration.

- **Endpoint:** `POST /api/v2/tools/restore_campaign_history`
- **Token permission:** `campaigns`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `campaignId` | `string` | Yes | Campaign ID to restore |
| `historyId` | `string` | Yes | History entry ID to restore from |

### archive_campaign

Archive a DRAFT campaign. Archived campaigns are hidden from the main campaign list but can be restored with unarchive_campaign. Only DRAFT campaigns can be archived.

- **Endpoint:** `POST /api/v2/tools/archive_campaign`
- **Token permission:** `campaigns`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `campaignId` | `string` | Yes | Campaign ID to archive |

### unarchive_campaign

Restore an ARCHIVED campaign back to DRAFT status so it can be edited and scheduled again.

- **Endpoint:** `POST /api/v2/tools/unarchive_campaign`
- **Token permission:** `campaigns`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `campaignId` | `string` | Yes | Campaign ID to unarchive |

### delete_campaign

Delete a draft campaign by id.

**This is a dangerous action requiring an explicit second request. The first call returns a short-lived, one-use confirmation code bound to the organization, token, tool, and exact input.**

- **Endpoint:** `POST /api/v2/tools/delete_campaign`
- **Token permission:** `campaigns`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `campaignId` | `string` | Yes |  |
| `confirmationCode` | `number` | No | Required for dangerous actions. Include the code from the confirmation challenge to execute. |

## Subscribers

### list_subscribers

List subscribers in your organization. Filter by tag, status, or search query. Returns results with email, name, status, tags, sorted by creation date descending.

- **Endpoint:** `POST /api/v2/tools/list_subscribers`
- **Token permission:** `subscribers`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `limit` | `integer` | No | Default: `50`. |
| `status` | `"all" \| "SUBSCRIBED" \| "UNSUBSCRIBED" \| "PENDING_CONFIRMATION" \| "BOUNCED" \| "BANNED" \| "COMPLAINED" \| "TRANSACTIONAL"` | No | Default: `"all"`. |
| `tag` | `string` | No | Filter by tag name (case-insensitive) |
| `query` | `string` | No |  |
| `cursor` | `string` | No | Cursor for pagination (subscriber ID from previous page) |

### query_subscribers

List subscribers matching composed filters - the same engine the UI uses, cursor-paginated. Use it instead of `list_subscribers` when you need conditional filters (e.g. clicked link X in campaign Y AND is BANNED). Call get_skill({ type: "filters" }) for the filter shapes and operators.

- **Endpoint:** `POST /api/v2/tools/query_subscribers`
- **Token permission:** `subscribers`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `filters` | `object[]` | No | Array of filter objects - same shape as update_campaign_filters and create_segment. See get_skill({ type: "filters" }). Default: `[]`. |
| `status` | `"PENDING_CONFIRMATION" \| "SUBSCRIBED" \| "UNSUBSCRIBED" \| "BOUNCED" \| "BANNED" \| "COMPLAINED" \| "TRANSACTIONAL" \| "ALL"` | No | Subscriber status filter - applied in addition to the filters array. Defaults to ALL. Default: `"ALL"`. |
| `query` | `string` | No | Free-text search across email, name, and tag name. |
| `limit` | `integer` | No | Default: `50`. |
| `cursor` | `string` | No | Cursor for pagination (subscriber ID from previous page). |
| `fields` | `("id" \| "email" \| "name" \| "phone" \| "status" \| "createdAt" \| "updatedAt" \| "tags" \| "customFields" \| "revenue" \| "emailCount" \| "openCount" \| "clickCount")[]` | No | Subset of fields to include per subscriber. Defaults to id, email, name, status, createdAt, tags. Available: id, email, name, phone, status, createdAt, updatedAt, tags, customFields, revenue, emailCount, openCount, clickCount. |

### count_subscribers

Count subscribers matching composed filters - same engine as query_subscribers but returns only the total. Useful for previews before scheduling a campaign or building a segment.

- **Endpoint:** `POST /api/v2/tools/count_subscribers`
- **Token permission:** `subscribers`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `filters` | `object[]` | No | Array of filter objects - same shape as update_campaign_filters and create_segment. Default: `[]`. |
| `status` | `"PENDING_CONFIRMATION" \| "SUBSCRIBED" \| "UNSUBSCRIBED" \| "BOUNCED" \| "BANNED" \| "COMPLAINED" \| "TRANSACTIONAL" \| "ALL"` | No | Subscriber status filter - applied in addition to the filters array. Defaults to ALL. Default: `"ALL"`. |
| `query` | `string` | No | Free-text search across email, name, and tag name. |

### get_subscriber

Get detailed information about a specific subscriber by ID or email.

- **Endpoint:** `POST /api/v2/tools/get_subscriber`
- **Token permission:** `subscribers`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `subscriberId` | `string` | No |  |
| `email` | `string` | No |  |

### add_subscriber

Add a new subscriber or update an existing one. Supports tags and custom fields.

- **Endpoint:** `POST /api/v2/tools/add_subscriber`
- **Token permission:** `subscribers`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `email` | `string` | Yes |  |
| `name` | `string` | No |  |
| `phone` | `string` | No |  |
| `tags` | `string[]` | No | Default: `[]`. |
| `fields` | `object \| null` | No | Default: `{}`. |
| `replaceTags` | `boolean` | No | Default: `false`. |
| `resubscribe` | `boolean` | No | Default: `true`. |
| `triggerWorkflows` | `boolean` | No |  |
| `skipDoubleOptIn` | `boolean` | No |  |
| `ipAddress` | `string` | No |  |
| `country` | `string` | No |  |

### update_subscriber

Update a subscriber by ID or email.

- **Endpoint:** `POST /api/v2/tools/update_subscriber`
- **Token permission:** `subscribers`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `subscriber` | `string` | Yes |  |
| `email` | `string` | No |  |
| `name` | `string` | No |  |
| `phone` | `string` | No |  |
| `tags` | `string[]` | No |  |
| `fields` | `object \| null` | No | Default: `{}`. |
| `replaceTags` | `boolean` | No |  |
| `resubscribe` | `boolean` | No |  |
| `triggerWorkflows` | `boolean` | No |  |
| `skipDoubleOptIn` | `boolean` | No |  |
| `ipAddress` | `string` | No |  |
| `country` | `string` | No |  |

### delete_subscriber

Subscriber deletion is disabled. Use unsubscribe instead.

**This is a dangerous action requiring an explicit second request. The first call returns a short-lived, one-use confirmation code bound to the organization, token, tool, and exact input.**

- **Endpoint:** `POST /api/v2/tools/delete_subscriber`
- **Token permission:** `subscribers`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `subscriber` | `string` | Yes |  |
| `confirmationCode` | `number` | No | Required for dangerous actions. Include the code from the confirmation challenge to execute. |

### unsubscribe

Unsubscribe a subscriber by ID or email. The subscriber will no longer receive emails.

**This is a dangerous action requiring an explicit second request. The first call returns a short-lived, one-use confirmation code bound to the organization, token, tool, and exact input.**

- **Endpoint:** `POST /api/v2/tools/unsubscribe`
- **Token permission:** `subscribers`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `subscriberId` | `string` | No |  |
| `email` | `string` | No |  |
| `reason` | `string` | No |  |
| `confirmationCode` | `number` | No | Required for dangerous actions. Include the code from the confirmation challenge to execute. |

### bulk_add_tags

Add one or more tags to multiple subscribers at once. Max 100 subscribers and 20 tags per call. Emits a tag.added event for each newly added tag, so tag-based workflow triggers (including Workflow event triggers) will fire exactly like when tags are added from the app.

**This is a dangerous action requiring an explicit second request. The first call returns a short-lived, one-use confirmation code bound to the organization, token, tool, and exact input.**

- **Endpoint:** `POST /api/v2/tools/bulk_add_tags`
- **Token permission:** `subscribers`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `subscriberIds` | `string[]` | Yes |  |
| `tagNames` | `string[]` | Yes |  |
| `confirmationCode` | `number` | No | Required for dangerous actions. Include the code from the confirmation challenge to execute. |

### bulk_remove_tags

Remove one or more tags from multiple subscribers. Max 100 subscribers and 20 tags per call. Emits a tag.removed event for each removed tag, so tag-based workflow triggers and exit rules react exactly like when tags are removed from the app.

**This is a dangerous action requiring an explicit second request. The first call returns a short-lived, one-use confirmation code bound to the organization, token, tool, and exact input.**

- **Endpoint:** `POST /api/v2/tools/bulk_remove_tags`
- **Token permission:** `subscribers`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `subscriberIds` | `string[]` | Yes |  |
| `tagNames` | `string[]` | Yes |  |
| `confirmationCode` | `number` | No | Required for dangerous actions. Include the code from the confirmation challenge to execute. |

### create_bulk_operation

Queue a durable subscriber bulk action (same pipeline as Subscribers → Actions). Returns immediately with an operationId. Poll get_bulk_operation until status is COMPLETED, PARTIALLY_FAILED, or FAILED. Do not use bulk_add_tags / add_subscribers_to_workflow for large audiences.

**This is a dangerous action requiring an explicit second request. The first call returns a short-lived, one-use confirmation code bound to the organization, token, tool, and exact input.**

- **Endpoint:** `POST /api/v2/tools/create_bulk_operation`
- **Token permission:** `subscribers`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `action` | `"ADD_TAGS" \| "REMOVE_TAGS" \| "UNSUBSCRIBE" \| "SUBSCRIBE" \| "BAN" \| "ADD_TO_WORKFLOW" \| "REMOVE_FROM_WORKFLOW"` | Yes | Same actions as Subscribers → Actions. Large audiences are queued, not applied in this request. |
| `idempotencyKey` | `string` | No | Reuse to avoid double-queue. Generated if omitted. |
| `subscriberIds` | `string[]` | No | Explicit IDs. Omit to use the current filters/query/status. |
| `sourceOperationId` | `string` | No | Reuse a previous operation's frozen audience. |
| `filters` | `object[]` | No | Same filter objects as query_subscribers. |
| `query` | `string` | No | Free-text search across email, name, and tag name. |
| `status` | `"PENDING_CONFIRMATION" \| "SUBSCRIBED" \| "UNSUBSCRIBED" \| "BOUNCED" \| "BANNED" \| "COMPLAINED" \| "TRANSACTIONAL" \| "ALL"` | No |  |
| `tags` | `string[]` | No |  |
| `reason` | `string` | No | Optional unsubscribe reason. |
| `workflowId` | `string` | No |  |
| `stepId` | `string` | No | Starting step when action is ADD_TO_WORKFLOW. |
| `confirmationCode` | `number` | No | Required for dangerous actions. Include the code from the confirmation challenge to execute. |

### get_bulk_operation

Read one durable subscriber bulk operation: status, progress counts, and last error. Poll until done is true.

- **Endpoint:** `POST /api/v2/tools/get_bulk_operation`
- **Token permission:** `subscribers`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `operationId` | `string` | Yes |  |

### list_bulk_operations

List the 20 most recent visible durable subscriber bulk operations for this organization.

- **Endpoint:** `POST /api/v2/tools/list_bulk_operations`
- **Token permission:** `subscribers`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** read

No parameters.

### create_event

Create a custom event for a subscriber. Useful for tracking purchases, conversions, or other actions.

- **Endpoint:** `POST /api/v2/tools/create_event`
- **Token permission:** `subscribers`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `subscriberId` | `string` | No |  |
| `email` | `string` | No |  |
| `eventType` | `"SUBSCRIBER_PAYMENT" \| "SUBSCRIBER_REFUND"` | Yes |  |
| `amount` | `number` | No |  |
| `currency` | `string` | No | Default: `"USD"`. |
| `product` | `string` | No | Product identifier. Workflow triggers and success goals filter payments and refunds on it. |
| `metadata` | `object` | No |  |

### list_subscriber_events

List events for a subscriber. Use this to check if an email was delivered, opened, clicked, bounced, etc. Filter by event type to find specific events.

- **Endpoint:** `POST /api/v2/tools/list_subscriber_events`
- **Token permission:** `subscribers`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `subscriberId` | `string` | No |  |
| `email` | `string` | No |  |
| `eventType` | `"SUBSCRIBED" \| "UNSUBSCRIBED" \| "TAG_ADDED" \| "TAG_REMOVED" \| "EMAIL_OPENED" \| "EMAIL_CLICKED" \| "EMAIL_SENT" \| "EMAIL_DELIVERY_DELAYED" \| "WORKFLOW_STARTED" \| "WORKFLOW_COMPLETED" \| "WORKFLOW_CANCELED" \| "FIELD_UPDATED" \| "EMAIL_BOUNCED" \| "EMAIL_COMPLAINED" \| "EMAIL_RECEIVED" \| "WEBHOOK_EXECUTED" \| "SUBSCRIBER_PAYMENT" \| "SUBSCRIBER_REFUND"` | No |  |
| `limit` | `integer` | No | Default: `50`. |
| `after` | `string` | No |  |

### get_subscriber_emails

Get emails sent to a subscriber with their delivery status. Use this to check if emails were delivered, bounced, or are pending. Shows campaign name, subject, and detailed status.

- **Endpoint:** `POST /api/v2/tools/get_subscriber_emails`
- **Token permission:** `subscribers`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `subscriberId` | `string` | No |  |
| `email` | `string` | No |  |
| `status` | `"PENDING" \| "SENT" \| "DELIVERED" \| "BOUNCED" \| "COMPLAINED"` | No |  |
| `limit` | `integer` | No | Default: `20`. |

## Workflows

### list_workflows

List graph-based workflow automations.

- **Endpoint:** `POST /api/v2/tools/list_workflows`
- **Token permission:** `workflows`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `limit` | `integer` | No | Default: `20`. |
| `query` | `string` | No |  |

### get_workflow

Get a Workflow draft graph, published version information, settings, and active subscriber count.

- **Endpoint:** `POST /api/v2/tools/get_workflow`
- **Token permission:** `workflows`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `workflowId` | `string` | Yes |  |

### create_workflow

Create a graph-based Workflow draft. It must be configured and published before it can enroll subscribers.

- **Endpoint:** `POST /api/v2/tools/create_workflow`
- **Token permission:** `workflows`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | Yes |  |

### configure_workflow_draft

Configure an entire Workflow draft (steps, edges, waits, branches, actions, goals, settings) in one atomic operation. Never publishes or sends. Call get_skill({ type: 'workflow' }) then get_workflow first - the skill documents the step types, edges, eventFilters keys and the EMAIL-step rule that replaces create_campaign and edit_campaign.

- **Endpoint:** `POST /api/v2/tools/configure_workflow_draft`
- **Token permission:** `workflows`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `workflowId` | `string` | Yes |  |
| `expectedUpdatedAt` | `string` | Yes | Exact updatedAt returned by get_workflow |
| `settings` | `object` | No |  |
| `steps` | `object[]` | Yes | Complete replacement list of Workflow steps |
| `edges` | `object[]` | Yes | Complete replacement list of directed graph edges |
| `successGoals` | `object[]` | No |  |

### update_workflow_draft

Replace a Workflow draft graph atomically. The full {steps, edges} definition is validated before it is saved. Omitted successGoals, exitRules and exitMode keep the current draft's values; pass an explicit value to replace them, or [] to clear successGoals/exitRules. Every EMAIL step is ownership-checked; foreign or broadcast campaign IDs are duplicated into dedicated internal WORKFLOW emails before saving.

- **Endpoint:** `POST /api/v2/tools/update_workflow_draft`
- **Token permission:** `workflows`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `workflowId` | `string` | Yes |  |
| `expectedUpdatedAt` | `string` | Yes |  |
| `definition` | `object` | Yes | Complete Workflow graph. Omitted successGoals/exitRules/exitMode are preserved from the current draft; [] clears them. Use configure_workflow_draft to add EMAIL steps with inline content. Existing campaign IDs are ownership-checked and duplicated rather than linked when they do not belong to that exact step. |

### publish_workflow

Validate and publish the current Workflow draft as a new immutable version. Activation: publishing never deactivates a live workflow and never re-activates a paused one; pass activate:true to go live, activate:false to publish paused. When omitted, only a first publish (DRAFT) activates.

**This is a dangerous action requiring an explicit second request. The first call returns a short-lived, one-use confirmation code bound to the organization, token, tool, and exact input.**

- **Endpoint:** `POST /api/v2/tools/publish_workflow`
- **Token permission:** `workflows`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `workflowId` | `string` | Yes |  |
| `activate` | `boolean` | No | true: activate after publishing. false: keep the workflow paused. Omitted: first publish activates, a paused workflow stays paused. |
| `confirmationCode` | `number` | No | Required for dangerous actions. Include the code from the confirmation challenge to execute. |

### update_workflow_status

Activate, pause, or archive a published Workflow.

**This is a dangerous action requiring an explicit second request. The first call returns a short-lived, one-use confirmation code bound to the organization, token, tool, and exact input.**

- **Endpoint:** `POST /api/v2/tools/update_workflow_status`
- **Token permission:** `workflows`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `workflowId` | `string` | Yes |  |
| `status` | `"ACTIVE" \| "PAUSED" \| "ARCHIVED"` | Yes |  |
| `confirmationCode` | `number` | No | Required for dangerous actions. Include the code from the confirmation challenge to execute. |

### add_subscriber_to_workflow

Manually enroll one subscriber in an active, published Workflow. Optional stepId starts at that published step. Bypasses trigger events and trigger audience filters; exit rules still apply. For a filtered list, use add_subscribers_to_workflow.

- **Endpoint:** `POST /api/v2/tools/add_subscriber_to_workflow`
- **Token permission:** `workflows`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `workflowId` | `string` | Yes |  |
| `subscriberId` | `string` | No |  |
| `email` | `string` | No |  |
| `stepId` | `string` | No | Published step to start from. Omit to start at the trigger. |

### add_subscribers_to_workflow

Manually enroll a filtered subscriber set in an active published Workflow — same as Subscribers → Select all → Add to workflow, but synchronous and capped. For large audiences use create_bulk_operation({ action: 'ADD_TO_WORKFLOW' }) then poll get_bulk_operation. Use the same filters as query_subscribers. Call dryRun:true first to preview the match count and published steps. Optional stepId starts at that step. Bypasses trigger rules; does not bypass exit rules. Call get_skill({ type: 'docs', page: 'workflows/workflow-manual-enrollment' }) for the product rules.

**This is a dangerous action requiring an explicit second request. The first call returns a short-lived, one-use confirmation code bound to the organization, token, tool, and exact input.**

- **Endpoint:** `POST /api/v2/tools/add_subscribers_to_workflow`
- **Token permission:** `workflows`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `workflowId` | `string` | Yes |  |
| `stepId` | `string` | No | Published step to start from. Omit to start at the trigger. |
| `filters` | `object[]` | No | Same filter objects as query_subscribers. Call get_skill({ type: "filters" }) for shapes. Default: `[]`. |
| `query` | `string` | No | Free-text search across email, name, and tag name. |
| `status` | `"PENDING_CONFIRMATION" \| "SUBSCRIBED" \| "UNSUBSCRIBED" \| "BOUNCED" \| "BANNED" \| "COMPLAINED" \| "TRANSACTIONAL" \| "ALL"` | No | Default: `"SUBSCRIBED"`. |
| `subscriberIds` | `string[]` | No | Explicit IDs instead of filters. Max 500. |
| `dryRun` | `boolean` | No | If true, return the match count and published steps only. Default: `false`. |
| `confirmationCode` | `number` | No | Required for dangerous actions. Include the code from the confirmation challenge to execute. |

### remove_subscriber_from_workflow

Cancel active Workflow runs for a subscriber.

- **Endpoint:** `POST /api/v2/tools/remove_subscriber_from_workflow`
- **Token permission:** `workflows`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `workflowId` | `string` | Yes |  |
| `subscriberId` | `string` | No |  |
| `email` | `string` | No |  |

### get_subscriber_workflow_runs

List a subscriber's workflow runs in this organization: status, current step, and nextActionAt. Active and waiting runs first, then recent completed or failed runs. Optional workflowId narrows to one workflow.

- **Endpoint:** `POST /api/v2/tools/get_subscriber_workflow_runs`
- **Token permission:** `workflows`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `subscriberId` | `string` | No |  |
| `email` | `string` | No |  |
| `workflowId` | `string` | No | If set, only return runs for this workflow. |

### fast_forward_workflow_subscriber

Expire the current WAIT or WAIT_UNTIL timer on one subscriber's run and resume the runner immediately. The next EMAIL or ACTION executes for real. One call completes one wait. WAIT_UNTIL follows the TIMEOUT edge. Inspect first with get_subscriber_workflow_runs. Not for EMAIL delivery parks or jumping to an arbitrary step.

- **Endpoint:** `POST /api/v2/tools/fast_forward_workflow_subscriber`
- **Token permission:** `workflows`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `workflowId` | `string` | Yes |  |
| `subscriberId` | `string` | No |  |
| `email` | `string` | No |  |
| `workflowSubscriberId` | `string` | No | Required when the subscriber has more than one waiting run in this workflow. |

### delete_workflow

Delete an unused Workflow draft, or archive it when published versions or run history must be preserved.

**This is a dangerous action requiring an explicit second request. The first call returns a short-lived, one-use confirmation code bound to the organization, token, tool, and exact input.**

- **Endpoint:** `POST /api/v2/tools/delete_workflow`
- **Token permission:** `workflows`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `workflowId` | `string` | Yes |  |
| `confirmationCode` | `number` | No | Required for dangerous actions. Include the code from the confirmation challenge to execute. |

### list_workflow_groups

List Workflow groups with their conflict strategy and assigned workflow count. Call this before assigning a groupId; never invent a group ID.

- **Endpoint:** `POST /api/v2/tools/list_workflow_groups`
- **Token permission:** `workflows`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `query` | `string` | No |  |
| `limit` | `integer` | No | Default: `50`. |

### get_workflow_group

Get a Workflow group, its assigned workflows, and current active-run counts.

- **Endpoint:** `POST /api/v2/tools/get_workflow_group`
- **Token permission:** `workflows`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `groupId` | `string` | Yes |  |

### create_workflow_group

Create a Workflow group. The operation is idempotent when a case-insensitive name match already has the requested strategy.

- **Endpoint:** `POST /api/v2/tools/create_workflow_group`
- **Token permission:** `workflows`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | Yes |  |
| `conflictStrategy` | `"CANCEL_EXISTING" \| "SKIP_NEW" \| "RUN_FIRST_STEP"` | No | Default: `"CANCEL_EXISTING"`. |

### update_workflow_group

Rename a Workflow group or change its conflict strategy for future enrollments. Requires a fresh get_workflow_group read and optimistic concurrency.

- **Endpoint:** `POST /api/v2/tools/update_workflow_group`
- **Token permission:** `workflows`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `groupId` | `string` | Yes |  |
| `expectedUpdatedAt` | `string` | Yes |  |
| `name` | `string` | No |  |
| `conflictStrategy` | `"CANCEL_EXISTING" \| "SKIP_NEW" \| "RUN_FIRST_STEP"` | No |  |

### set_workflow_group

Assign a Workflow to an existing group, or detach it with groupId:null, without replacing its graph or publishing. The change affects future enrollments only.

- **Endpoint:** `POST /api/v2/tools/set_workflow_group`
- **Token permission:** `workflows`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `workflowId` | `string` | Yes |  |
| `expectedUpdatedAt` | `string` | Yes |  |
| `groupId` | `string \| null` | Yes |  |

### delete_workflow_group

Permanently delete a Workflow group after a fresh read. Refuses to delete an assigned group unless unassignWorkflows:true is explicit.

**This is a dangerous action requiring an explicit second request. The first call returns a short-lived, one-use confirmation code bound to the organization, token, tool, and exact input.**

- **Endpoint:** `POST /api/v2/tools/delete_workflow_group`
- **Token permission:** `workflows`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `groupId` | `string` | Yes |  |
| `expectedName` | `string` | Yes |  |
| `expectedUpdatedAt` | `string` | Yes |  |
| `unassignWorkflows` | `boolean` | No | Default: `false`. |
| `confirmationCode` | `number` | No | Required for dangerous actions. Include the code from the confirmation challenge to execute. |

## Segments

### list_segments

List saved subscriber segments in your organization.

- **Endpoint:** `POST /api/v2/tools/list_segments`
- **Token permission:** `audience`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `limit` | `integer` | No | Default: `20`. |

### get_segment

Get detailed information about a specific segment including all filter configurations.

- **Endpoint:** `POST /api/v2/tools/get_segment`
- **Token permission:** `audience`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `segmentId` | `string` | Yes |  |

### create_segment

Create a new subscriber segment with filter configuration.

- **Endpoint:** `POST /api/v2/tools/create_segment`
- **Token permission:** `audience`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | Yes |  |
| `filters` | `object[]` | No | Default: `[]`. |

### update_segment

Update a segment's name or filter configuration.

- **Endpoint:** `POST /api/v2/tools/update_segment`
- **Token permission:** `audience`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `segmentId` | `string` | Yes |  |
| `name` | `string` | No |  |
| `filters` | `object[]` | No |  |

### delete_segment

Permanently delete a segment.

**This is a dangerous action requiring an explicit second request. The first call returns a short-lived, one-use confirmation code bound to the organization, token, tool, and exact input.**

- **Endpoint:** `POST /api/v2/tools/delete_segment`
- **Token permission:** `audience`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `segmentId` | `string` | Yes |  |
| `confirmationCode` | `number` | No | Required for dangerous actions. Include the code from the confirmation challenge to execute. |

### duplicate_segment

Create a copy of a segment with all its filters.

- **Endpoint:** `POST /api/v2/tools/duplicate_segment`
- **Token permission:** `audience`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `segmentId` | `string` | Yes |  |
| `newName` | `string` | No |  |

## Tags

### list_tags

List all tags in your organization.

- **Endpoint:** `POST /api/v2/tools/list_tags`
- **Token permission:** `audience`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

No parameters.

### create_tag

Create a new tag in your organization.

- **Endpoint:** `POST /api/v2/tools/create_tag`
- **Token permission:** `audience`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | Yes |  |

### get_tag

Get a tag by id or name, including subscriber count.

- **Endpoint:** `POST /api/v2/tools/get_tag`
- **Token permission:** `audience`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `tag` | `string` | Yes |  |

### update_tag

Rename a tag by id or name.

- **Endpoint:** `POST /api/v2/tools/update_tag`
- **Token permission:** `audience`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `tag` | `string` | Yes |  |
| `name` | `string` | Yes |  |

### delete_tag

Delete a tag by ID or name. This will remove the tag from all subscribers.

**This is a dangerous action requiring an explicit second request. The first call returns a short-lived, one-use confirmation code bound to the organization, token, tool, and exact input.**

- **Endpoint:** `POST /api/v2/tools/delete_tag`
- **Token permission:** `audience`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `tagId` | `string` | No |  |
| `name` | `string` | No |  |
| `confirmationCode` | `number` | No | Required for dangerous actions. Include the code from the confirmation challenge to execute. |

### get_or_create_tags

Get existing tags or create new ones by name. Tags are labels used to categorize subscribers and trigger workflows. This tool automatically creates any tags that don't exist yet.

- **Endpoint:** `POST /api/v2/tools/get_or_create_tags`
- **Token permission:** `audience`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `names` | `string[]` | Yes | Array of tag names to get or create |

## Analytics

### get_org_stats

Get overall organization statistics including subscriber counts, campaign metrics, and resource totals.

- **Endpoint:** `POST /api/v2/tools/get_org_stats`
- **Token permission:** `analytics`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

No parameters.

### get_subscriber_growth

Analyze subscriber growth over a specified time period. Shows new subscribers, unsubscribes, and net growth.

- **Endpoint:** `POST /api/v2/tools/get_subscriber_growth`
- **Token permission:** `analytics`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `days` | `integer` | No | Default: `30`. |

### get_subscriber_stats

Get subscriber growth and status statistics for your organization.

- **Endpoint:** `POST /api/v2/tools/get_subscriber_stats`
- **Token permission:** `analytics`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

No parameters.

### get_subscribers_by_tag

Get all subscribers that have a specific tag. Useful for understanding tag usage.

- **Endpoint:** `POST /api/v2/tools/get_subscribers_by_tag`
- **Token permission:** `analytics`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `tagName` | `string` | Yes |  |
| `limit` | `integer` | No | Default: `10`. |

### count_subscribers_by_status

Get subscriber count by status. Optionally filter by a specific status.

- **Endpoint:** `POST /api/v2/tools/count_subscribers_by_status`
- **Token permission:** `analytics`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | `"SUBSCRIBED" \| "UNSUBSCRIBED" \| "BOUNCED" \| "BANNED" \| "COMPLAINED" \| "TRANSACTIONAL"` | No |  |

## Email

### send_email

Send a transactional email. Provide exactly one of html, tiptap, or markdown. Markdown is rendered to HTML. text is generated from the rendered HTML when omitted.

**This is a dangerous action requiring an explicit second request. The first call returns a short-lived, one-use confirmation code bound to the organization, token, tool, and exact input.**

- **Endpoint:** `POST /api/v2/tools/send_email`
- **Token permission:** `emails`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `from` | `string` | Yes |  |
| `to` | `string \| string[]` | Yes | One recipient. Multiple addresses are not supported. |
| `subject` | `string` | Yes |  |
| `html` | `string` | No |  |
| `tiptap` | `string \| object` | No |  |
| `markdown` | `string` | No |  |
| `text` | `string` | No |  |
| `reply_to` | `string \| string[]` | No |  |
| `cc` | `string \| string[]` | No |  |
| `bcc` | `string \| string[]` | No |  |
| `headers` | `object` | No |  |
| `tags` | `object[]` | No |  |
| `preview` | `string` | No |  |
| `tracking` | `object` | No | Open and click tracking. Both default to true. |
| `idempotencyKey` | `string` | No |  |
| `attachments` | `any` | No |  |
| `template` | `any` | No |  |
| `topic_id` | `any` | No |  |
| `scheduled_at` | `any` | No |  |
| `react` | `any` | No |  |
| `content` | `any` | No |  |
| `contentType` | `any` | No |  |
| `confirmationCode` | `number` | No | Required for dangerous actions. Include the code from the confirmation challenge to execute. |

### send_batch_emails

Send up to 100 transactional emails. Same body as send_email.

**This is a dangerous action requiring an explicit second request. The first call returns a short-lived, one-use confirmation code bound to the organization, token, tool, and exact input.**

- **Endpoint:** `POST /api/v2/tools/send_batch_emails`
- **Token permission:** `emails`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `emails` | `object[]` | Yes |  |
| `idempotencyKey` | `string` | No |  |
| `confirmationCode` | `number` | No | Required for dangerous actions. Include the code from the confirmation challenge to execute. |

### get_email

Retrieve a single sent or queued transactional email.

- **Endpoint:** `POST /api/v2/tools/get_email`
- **Token permission:** `emails`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | Yes |  |

### list_emails

List transactional emails sent through the API, newest first. Paginate with after/before ids.

- **Endpoint:** `POST /api/v2/tools/list_emails`
- **Token permission:** `emails`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `limit` | `integer` | No | Default: `20`. |
| `after` | `string` | No |  |
| `before` | `string` | No |  |

### verify_email

Verify an email address. Returns an email_verification object. accept is the policy decision. result is deliverable, risky, undeliverable, or unknown. suggestion is a better address when the domain looks like a typo. issues lists every error or warning. score is 0-100 or null.

- **Endpoint:** `POST /api/v2/tools/verify_email`
- **Token permission:** `emails`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `email` | `string` | Yes |  |

## Deliverability

### get_deliverability_health

Assess the organization's deliverability health (sender reputation). Omit sinceDays for the all-time trust score; pass it for a windowed sent/delivered/bounced/complained report with verdicts.

- **Endpoint:** `POST /api/v2/tools/get_deliverability_health`
- **Token permission:** `domains`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `sinceDays` | `integer` | No |  |

### list_suppressed_subscribers

List suppressed subscribers (BOUNCED and/or COMPLAINED), most recently suppressed first, with pagination. Each entry includes the most recent bounce/complaint event details (bounceCategory, reason, bounceType, provider, complaintFeedbackType).

- **Endpoint:** `POST /api/v2/tools/list_suppressed_subscribers`
- **Token permission:** `domains`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | `"BOUNCED" \| "COMPLAINED"` | No |  |
| `page` | `integer` | No | Default: `1`. |
| `pageSize` | `integer` | No | Default: `50`. |

### reactivate_subscriber

Reactivate a suppressed subscriber (BOUNCED, COMPLAINED, or UNSUBSCRIBED) by setting their status back to SUBSCRIBED. Refuses hard bounces (bounceCategory NO_SUCH_USER or INTERNAL / SES suppression list) unless 'force' is true.

**This is a dangerous action requiring an explicit second request. The first call returns a short-lived, one-use confirmation code bound to the organization, token, tool, and exact input.**

- **Endpoint:** `POST /api/v2/tools/reactivate_subscriber`
- **Token permission:** `domains`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `subscriberId` | `string` | Yes |  |
| `force` | `boolean` | No |  |
| `confirmationCode` | `number` | No | Required for dangerous actions. Include the code from the confirmation challenge to execute. |

## Domains

### list_domains

List the organization's email sending domains with their verification status. Call get_skill({ type: 'sending' }) for the full domain and sender setup flow.

- **Endpoint:** `POST /api/v2/tools/list_domains`
- **Token permission:** `domains`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

No parameters.

### create_domain

Register a new email sending domain and return the DNS records the user must add. Call get_skill({ type: 'sending' }) first for the full setup flow.

- **Endpoint:** `POST /api/v2/tools/create_domain`
- **Token permission:** `domains`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `domain` | `string` | Yes | The domain to register, e.g. mail.example.com |
| `region` | `"AP_SOUTHEAST_2" \| "EU_CENTRAL_1" \| "US_WEST_1"` | No | SES sending region for this domain; defaults to Frankfurt. Cannot be changed after creation. |

### get_domain

Retrieve one sending domain by id or domain name.

- **Endpoint:** `POST /api/v2/tools/get_domain`
- **Token permission:** `domains`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `domain` | `string` | Yes |  |

### get_domain_dns_records

Get a domain's DNS records with their per-record verification status, to show the user what to configure and what is still pending.

- **Endpoint:** `POST /api/v2/tools/get_domain_dns_records`
- **Token permission:** `domains`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `domainId` | `string` | Yes | The MailDomain id |

### verify_domain

Re-check a domain's verification, DKIM and MAIL FROM state and return the updated per-record DNS statuses. Call get_skill({ type: 'sending' }) for what to do with the result.

- **Endpoint:** `POST /api/v2/tools/verify_domain`
- **Token permission:** `domains`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `domainId` | `string` | Yes | The MailDomain id or hostname |

### create_email_sender

Create an email sender (from-address) as localPart@domain. The domain does not need to be verified yet — the sender cannot send until it is. Call get_skill({ type: 'sending' }) for the prerequisites.

- **Endpoint:** `POST /api/v2/tools/create_email_sender`
- **Token permission:** `domains`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `displayName` | `string` | Yes | The display name shown in the From header |
| `localPart` | `string` | Yes | The part before the @, e.g. 'hello' in hello@example.com |
| `domainId` | `string` | Yes | The MailDomain id to attach the sender to |
| `replyTo` | `string \| null` | No | Optional reply-to email address |

### list_web_domains

List the organization's web domains (capture pages and click-tracking hosts) with their verification status. Use verify_web_domain to re-check a host. Sending domains are list_domains.

- **Endpoint:** `POST /api/v2/tools/list_web_domains`
- **Token permission:** `domains`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

No parameters.

### get_web_domain

Retrieve one web domain by id (wdom_...) or hostname, including the CNAME the user must add. Sending domains are get_domain.

- **Endpoint:** `POST /api/v2/tools/get_web_domain`
- **Token permission:** `domains`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `domainId` | `string` | Yes | The WebDomain id (wdom_...) or hostname |

### verify_web_domain

Re-check a web domain's Cloudflare custom-hostname verification (the Check Verification Status button on /domains/web/:id). Returns the updated status and CNAME. Use this for wdom_... hosts — verify_domain is SES sending domains only.

- **Endpoint:** `POST /api/v2/tools/verify_web_domain`
- **Token permission:** `domains`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `domainId` | `string` | Yes | The WebDomain id (wdom_...) or hostname |

## Capture pages

### list_capture_pages

List the organization's capture pages (email signup landing pages) with publish status, views, signups and URLs. Most recently edited first.

- **Endpoint:** `POST /api/v2/tools/list_capture_pages`
- **Token permission:** `campaigns`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `query` | `string` | No | Optional case-insensitive filter on the page name or slug. |

### get_capture_page

Get one capture page: settings, tags, publish status, URLs and (by default) its current draft TSX and CSS. Read it before editing the code.

- **Endpoint:** `POST /api/v2/tools/get_capture_page`
- **Token permission:** `campaigns`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `capturePageId` | `string` | Yes | Capture page ID (from list_capture_pages) |
| `includeCode` | `boolean` | No | Include the draft TSX and CSS (default true). Default: `true`. |

### list_capture_page_templates

List the built-in capture page templates. Pass a templateId to create_capture_page to start from a finished design, or set includeCode to read a template's TSX as a reference.

- **Endpoint:** `POST /api/v2/tools/list_capture_page_templates`
- **Token permission:** `campaigns`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `includeCode` | `boolean` | No | Include each template's TSX and CSS. Default: `false`. |

### validate_capture_page_code

Check capture page TSX against the runtime rules without saving anything. Use it to iterate on code locally before update_capture_page.

- **Endpoint:** `POST /api/v2/tools/validate_capture_page_code`
- **Token permission:** `campaigns`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `tsx` | `string` | Yes | Complete page TSX. No imports; must contain `export default function App()`. See get_skill type capture_page. |

### create_capture_page

Create a capture page as an unpublished draft. Provide your own tsx/css (read get_skill type capture_page first), or a templateId, or neither for an empty page the user finishes in the AI builder. Returns the builder URL where the user previews and edits it. Never publishes.

- **Endpoint:** `POST /api/v2/tools/create_capture_page`
- **Token permission:** `campaigns`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | No | Display name. Made unique automatically. |
| `prompt` | `string` | No | What the page is for. Used to derive a name when omitted. |
| `templateId` | `string` | No | Template to start from (list_capture_page_templates). |
| `tsx` | `string` | No | Complete page TSX. No imports; must contain `export default function App()`. See get_skill type capture_page. |
| `css` | `string` | No | Complete page stylesheet: the `:root` theme tokens plus optional @keyframes. Replaces the previous CSS. |
| `slug` | `string` | No | Public URL path, e.g. free-seo-guide. Derived from the name when omitted. |
| `metaTitle` | `string` | No | SEO title |
| `metaDescription` | `string` | No | SEO description |
| `tags` | `string[]` | No | Tags added to every subscriber who signs up through this page. |

### update_capture_page

Replace the draft code of a capture page. Send the complete TSX and/or the complete CSS (omitted parts stay unchanged). The live page only changes on publish_capture_page.

- **Endpoint:** `POST /api/v2/tools/update_capture_page`
- **Token permission:** `campaigns`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `capturePageId` | `string` | Yes | Capture page ID (from list_capture_pages) |
| `tsx` | `string` | No | Complete page TSX. No imports; must contain `export default function App()`. See get_skill type capture_page. |
| `css` | `string` | No | Complete page stylesheet: the `:root` theme tokens plus optional @keyframes. Replaces the previous CSS. |

### update_capture_page_settings

Rename a capture page, change its URL slug, SEO metadata, or the tags given to new signups. Omitted fields stay unchanged; tags replace the whole list.

- **Endpoint:** `POST /api/v2/tools/update_capture_page_settings`
- **Token permission:** `campaigns`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `capturePageId` | `string` | Yes | Capture page ID (from list_capture_pages) |
| `name` | `string` | No |  |
| `slug` | `string` | No |  |
| `metaTitle` | `string` | No |  |
| `metaDescription` | `string` | No |  |
| `tags` | `string[]` | No |  |

### publish_capture_page

Publish the current draft as a new live version (v1, v2...). Only call when the user explicitly asks to publish or go live. Reversible with rollback_capture_page.

- **Endpoint:** `POST /api/v2/tools/publish_capture_page`
- **Token permission:** `campaigns`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `capturePageId` | `string` | Yes | Capture page ID (from list_capture_pages) |

### list_capture_page_versions

List the published versions of a capture page and which one is live.

- **Endpoint:** `POST /api/v2/tools/list_capture_page_versions`
- **Token permission:** `campaigns`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `capturePageId` | `string` | Yes | Capture page ID (from list_capture_pages) |

### rollback_capture_page

Restore a published version: it becomes both the draft and the live page. Only on explicit user request; call list_capture_page_versions first.

- **Endpoint:** `POST /api/v2/tools/rollback_capture_page`
- **Token permission:** `campaigns`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `capturePageId` | `string` | Yes | Capture page ID (from list_capture_pages) |
| `version` | `string` | Yes | Version to restore, e.g. v2 |

### delete_capture_page

Permanently delete a capture page with its versions and analytics. Only on explicit user request.

**This is a dangerous action requiring an explicit second request. The first call returns a short-lived, one-use confirmation code bound to the organization, token, tool, and exact input.**

- **Endpoint:** `POST /api/v2/tools/delete_capture_page`
- **Token permission:** `campaigns`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `capturePageId` | `string` | Yes | Capture page ID (from list_capture_pages) |
| `confirmationCode` | `number` | No | Required for dangerous actions. Include the code from the confirmation challenge to execute. |

## Images

### upload_image

Import an image from a public http/https URL into your organization media library. Fetches the image, validates it is an image under 2MB, uploads it to storage, and returns the hosted image with a URL that can be used directly in campaign image blocks.

- **Endpoint:** `POST /api/v2/tools/upload_image`
- **Token permission:** `campaigns`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `url` | `string` | Yes | Public http(s) URL of the image to import. |
| `name` | `string` | No | Optional display name for the imported image. |
| `description` | `string` | No | Short description of what the image shows (subject, colors, intended usage). Strongly recommended: this is what makes the image findable later with list_images. |

### list_images

Search the organization media library. The query matches both the image name and its description, so you can search by what the image shows (e.g. 'logo', 'hero photo'). Newest first.

- **Endpoint:** `POST /api/v2/tools/list_images`
- **Token permission:** `campaigns`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `query` | `string` | No | Optional case-insensitive search matched against both the image name and its description. |
| `capturePageId` | `string` | No | Only return images uploaded for this capture page. Omit to search the whole library. |
| `page` | `integer` | No | Default: `1`. |
| `pageSize` | `integer` | No | Default: `50`. |

### describe_image

Set or update the description of an image already in the media library. Use it right after a user uploads an image so it becomes findable later with list_images. Accepts either the image id or its URL.

- **Endpoint:** `POST /api/v2/tools/describe_image`
- **Token permission:** `campaigns`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `imageId` | `string` | No | Id of the image. Provide either imageId or url. |
| `url` | `string` | No | URL of the image. Provide either imageId or url. |
| `description` | `string` | Yes | What the image shows: subject, dominant colors, and how it should be used on the page. |
| `name` | `string` | No | Optional human-readable name replacing the raw filename. |

## Import / Export

### import_subscribers

Bulk import subscribers (up to 500 per call). Existing subscribers are updated, new ones created, and workflows are NOT triggered by default. Returns imported/updated/failed counts with per-row errors.

- **Endpoint:** `POST /api/v2/tools/import_subscribers`
- **Token permission:** `subscribers`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `subscribers` | `object[]` | Yes |  |
| `resubscribe` | `boolean` | No | Re-subscribe previously unsubscribed contacts. Default: `false`. |
| `triggerWorkflows` | `boolean` | No | Trigger automation workflows on import. Defaults to false for bulk imports. Default: `false`. |

### export_subscribers

Export a small subscriber sample inline (default 1000, max 10000). For the full filtered audience as CSV, use create_subscriber_export and poll get_subscriber_export.

- **Endpoint:** `POST /api/v2/tools/export_subscribers`
- **Token permission:** `subscribers`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | `"PENDING_CONFIRMATION" \| "SUBSCRIBED" \| "UNSUBSCRIBED" \| "BOUNCED" \| "BANNED" \| "COMPLAINED" \| "TRANSACTIONAL"` | No |  |
| `tag` | `string` | No | Filter by tag name (case-insensitive). |
| `format` | `"json" \| "csv"` | No | Default: `"json"`. |
| `limit` | `integer` | No | Default: `1000`. |

### create_subscriber_export

Queue a durable CSV export of the current subscriber filters (same pipeline as Subscribers → Export). Returns immediately with an exportId. Poll get_subscriber_export until done is true, then download from the app.

- **Endpoint:** `POST /api/v2/tools/create_subscriber_export`
- **Token permission:** `subscribers`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | `"PENDING_CONFIRMATION" \| "SUBSCRIBED" \| "UNSUBSCRIBED" \| "BOUNCED" \| "BANNED" \| "COMPLAINED" \| "TRANSACTIONAL" \| "ALL"` | No | Default: `"SUBSCRIBED"`. |
| `query` | `string` | No | Default: `""`. |
| `filters` | `object[]` | No | Default: `[]`. |
| `subscriberIds` | `string[]` | No |  |
| `columns` | `("email" \| "name" \| "phone" \| "status" \| "tags" \| "emailCount" \| "openCount" \| "clickCount" \| "createdAt")[]` | No | Default: `["email","name","tags","emailCount","openCount","clickCount","createdAt"]`. |
| `fieldIds` | `string[]` | No | Default: `[]`. |
| `idempotencyKey` | `string` | No | Reuse to avoid double-queue. Generated if omitted. |

### get_subscriber_export

Poll a durable subscriber CSV export. When done is true and downloadable is true, the CSV is ready in the Lumail app.

- **Endpoint:** `POST /api/v2/tools/get_subscriber_export`
- **Token permission:** `subscribers`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `exportId` | `string` | Yes |  |

### list_subscriber_exports

List the 10 most recent subscriber CSV exports.

- **Endpoint:** `POST /api/v2/tools/list_subscriber_exports`
- **Token permission:** `subscribers`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** read

No parameters.

## Web

### fetch_web_page

Fetch a web page and convert to markdown format

- **Endpoint:** `POST /api/v2/tools/fetch_web_page`
- **Token permission:** `settings`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `url` | `string` | Yes | The full URL of the web page to fetch (must start with http:// or https://) |

## Skills

### get_available_variables

Get all available variables for email content (built-in, organization, custom fields, snippets)

- **Endpoint:** `POST /api/v2/tools/get_available_variables`
- **Token permission:** `any`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

No parameters.

### get_skill

Get task-specific documentation. Call it BEFORE writing content, filters, or a workflow graph - never guess a format from memory. Types: 'campaign' (TipTap content + edit_campaign operations), 'filters' (subscriber/recipient filter shapes), 'workflow' (draft graph), 'sending' (sending domain, web domain, DNS and sender setup), 'images', 'snippets', 'templates', 'variables', 'copywriter', 'onboarding', 'capture_page' (capture page TSX/CSS contract and build workflow), 'docs' (full product docs catalog; pass page to read one markdown page).

- **Endpoint:** `POST /api/v2/tools/get_skill`
- **Token permission:** `any`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `type` | `"campaign" \| "snippets" \| "templates" \| "variables" \| "copywriter" \| "filters" \| "images" \| "sending" \| "workflow" \| "onboarding" \| "capture_page" \| "docs"` | Yes | Skill type to retrieve |
| `page` | `string` | No | Only for type=docs. Docs slug such as workflows/workflow-manual-enrollment. Omit to list every page. |

## Settings

### get_organization_variables

Read shared organization variables and the subscriber name fallback. Global keys use global- (e.g. global-number_member_editing_ai); insert {{global-number_member_editing_ai}} in email subjects, previews, HTML or links, or a variable node with that id in rich content.

- **Endpoint:** `POST /api/v2/tools/get_organization_variables`
- **Token permission:** `settings`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

No parameters.

### update_organization_variables

Set or delete shared organization variables. Pass changes with global- keys and string values; null deletes a key. Unspecified keys are preserved. Up to 100 global variables, 2000 characters per value. Values apply to newly prepared emails, never override recipient fields, and are not recalculated counters. The name key changes only the subscriber name fallback.

- **Endpoint:** `POST /api/v2/tools/update_organization_variables`
- **Token permission:** `settings`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `changes` | `object` | Yes |  |

### get_writing_style

Get the organization's default writing style for content generation including persona, audience, vocabulary, and rules.

- **Endpoint:** `POST /api/v2/tools/get_writing_style`
- **Token permission:** `settings`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

No parameters.

### update_writing_style

Modify writing style settings (persona, audience, vocabulary, rules)

- **Endpoint:** `POST /api/v2/tools/update_writing_style`
- **Token permission:** `settings`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | No |  |
| `persona` | `string` | No |  |
| `audience` | `string` | No |  |
| `vocabulary` | `string` | No |  |
| `rules` | `string` | No |  |

### get_email_senders

List all configured email senders (from addresses) for this organization.

- **Endpoint:** `POST /api/v2/tools/get_email_senders`
- **Token permission:** `settings`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

No parameters.

### get_custom_fields

List all custom subscriber field definitions for this organization.

- **Endpoint:** `POST /api/v2/tools/get_custom_fields`
- **Token permission:** `audience`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

No parameters.

### create_custom_field

Create a custom subscriber field definition by name. This only creates the definition (id + name). It does not set subscriber values or change subscription status. Reserved names like email, name, phone, country, and ip are rejected. Examples: has_double_opt_in, has_custom_domains.

- **Endpoint:** `POST /api/v2/tools/create_custom_field`
- **Token permission:** `audience`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | Yes | Field definition name, e.g. has_double_opt_in or has_custom_domains |

### rename_custom_field

Rename a custom subscriber field definition by id. Same reserved-name and duplicate checks as create. Does not change subscriber values.

- **Endpoint:** `POST /api/v2/tools/rename_custom_field`
- **Token permission:** `audience`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `fieldId` | `string` | Yes |  |
| `name` | `string` | Yes | New field definition name |

### delete_custom_field

Delete a custom subscriber field definition by id. This removes the definition and every stored value for that field. It does not resubscribe contacts.

**This is a dangerous action requiring an explicit second request. The first call returns a short-lived, one-use confirmation code bound to the organization, token, tool, and exact input.**

- **Endpoint:** `POST /api/v2/tools/delete_custom_field`
- **Token permission:** `audience`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `fieldId` | `string` | Yes |  |
| `confirmationCode` | `number` | No | Required for dangerous actions. Include the code from the confirmation challenge to execute. |

### get_email_snippets

List email snippets (reusable content blocks) for this organization. Use get_email_snippet to read a snippet's content.

- **Endpoint:** `POST /api/v2/tools/get_email_snippets`
- **Token permission:** `settings`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `includeArchived` | `boolean` | No | Include archived snippets (default: false) |

### get_email_snippet

Get a single email snippet by id (snp_...) including its full TipTap JSON content. Use this before update_email_snippet to read the current content.

- **Endpoint:** `POST /api/v2/tools/get_email_snippet`
- **Token permission:** `settings`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | Yes | Snippet id, e.g. snp_... |

### create_email_snippet

Create a new email snippet (reusable content block). Content is TipTap JSON with the same node types as campaigns: { type: "doc", content: [...] }. Snippets can then be referenced inside campaign content.

- **Endpoint:** `POST /api/v2/tools/create_email_snippet`
- **Token permission:** `settings`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | Yes | Snippet name |
| `description` | `string` | No | Optional description |
| `content` | `any` | No | TipTap JSON content: { type: "doc", content: [...] }. Omit to create an empty snippet. |

### update_email_snippet

Update an email snippet by id (snp_...): name, description, and/or its TipTap JSON content ({ type: "doc", content: [...] }, same node types as campaigns). Content updates apply everywhere the snippet is used. Read the current content with get_email_snippet first when editing.

- **Endpoint:** `POST /api/v2/tools/update_email_snippet`
- **Token permission:** `settings`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | Yes | Snippet id, e.g. snp_... |
| `name` | `string` | No | New snippet name |
| `description` | `string` | No | New description |
| `content` | `any` | No | New TipTap JSON content: { type: "doc", content: [...] }. Replaces the entire snippet content. |

### archive_email_snippet

Archive an email snippet by id (snp_...). Archived snippets are hidden from the snippet list but existing campaigns referencing them keep working. Restore with unarchive_email_snippet.

- **Endpoint:** `POST /api/v2/tools/archive_email_snippet`
- **Token permission:** `settings`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | Yes | Snippet id, e.g. snp_... |

### unarchive_email_snippet

Restore an archived email snippet by id (snp_...).

- **Endpoint:** `POST /api/v2/tools/unarchive_email_snippet`
- **Token permission:** `settings`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | Yes | Snippet id, e.g. snp_... |

### get_organization_settings

Read organization name, timezone, address, logo, and double opt-in. Call this before update_organization_settings or update_confirmation_email. For the inbox-hygiene migration (branding, why-you-receive, DOI, workflows) read get_skill({ type: 'docs', page: 'tutorials/migration-to-high-deliverability-emails' }).

- **Endpoint:** `POST /api/v2/tools/get_organization_settings`
- **Token permission:** `settings`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

No parameters.

### update_organization_settings

Update organization name, timezone, postal address (adresse), and/or doubleOptInEnabled. Enabling double opt-in makes new public signups PENDING_CONFIRMATION and sends the confirmation email. Existing SUBSCRIBED contacts stay subscribed. After enabling, customize the email with get_confirmation_email then update_confirmation_email. Workflows that start on subscriber created or tag added wait until confirmation.

- **Endpoint:** `POST /api/v2/tools/update_organization_settings`
- **Token permission:** `settings`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | No | Organization name |
| `timezone` | `string \| null` | No | Timezone in IANA format |
| `adresse` | `string \| null` | No | Postal address used as {{org-adresse}} in email footers |
| `doubleOptInEnabled` | `boolean` | No | When true, new public signups must confirm before campaigns or marketing workflows. Trusted backends should pass skipDoubleOptIn on subscriber create instead of turning this off. |

### get_confirmation_email

Read the native double-opt-in confirmation email (TipTap JSON, subject, preview). Required variable is {{confirmUrl}} on a button url or link href. Call this before update_confirmation_email. Same node types as campaigns — get_skill({ type: 'campaign' }).

- **Endpoint:** `POST /api/v2/tools/get_confirmation_email`
- **Token permission:** `settings`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

No parameters.

### update_confirmation_email

Replace the native double-opt-in confirmation email. Template is TipTap JSON { type: 'doc', content: [...] }. Must include {{confirmUrl}} in a button url or link href. Add visible branding and a why-you-receive line. Call get_confirmation_email first. get_skill({ type: 'campaign' }) for nodes.

- **Endpoint:** `POST /api/v2/tools/update_confirmation_email`
- **Token permission:** `settings`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `subject` | `string` | Yes | Inbox subject |
| `preview` | `string` | No | Inbox preview text. Empty string is allowed. |
| `template` | `any` | Yes | TipTap JSON: { type: "doc", content: [...] } |

### get_list_cleanup_settings

Read automatic list cleanup policy (off by default) and the re-engagement email (TipTap JSON, subject, preview). Enabling requires a completed preview receipt: preview_list_cleanup, poll get_list_cleanup_preview, then enable_list_cleanup.

- **Endpoint:** `POST /api/v2/tools/get_list_cleanup_settings`
- **Token permission:** `settings`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** read

No parameters.

### update_list_cleanup_settings

Save the cleanup interval and re-engagement toggle. Cannot enable. Disable with listCleanupEnabled: false. To enable, preview_list_cleanup then enable_list_cleanup with the receipt.

- **Endpoint:** `POST /api/v2/tools/update_list_cleanup_settings`
- **Token permission:** `settings`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `listCleanupEnabled` | `boolean` | No |  |
| `listCleanupInactivityDays` | `30 \| 90 \| 180` | No |  |
| `listCleanupReengagementEnabled` | `boolean` | No |  |

### preview_list_cleanup

Start an async preview of who would be unsubscribed. Saves the interval and re-engagement toggle, then returns a jobId. Poll get_list_cleanup_preview until status is completed and pass its receipt to enable_list_cleanup.

- **Endpoint:** `POST /api/v2/tools/preview_list_cleanup`
- **Token permission:** `settings`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `inactivityDays` | `30 \| 90 \| 180` | Yes |  |
| `reengagementEnabled` | `boolean` | Yes |  |

### get_list_cleanup_preview

Read a list cleanup preview started by preview_list_cleanup: status (queued, running, completed, error), counts, sample contacts, and the receipt once completed. Show the eligible count to the user before calling enable_list_cleanup.

- **Endpoint:** `POST /api/v2/tools/get_list_cleanup_preview`
- **Token permission:** `settings`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** read

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `jobId` | `string` | Yes | jobId returned by preview_list_cleanup |

### enable_list_cleanup

Enable automatic list cleanup from a completed preview receipt. Receipt expires in 10 minutes.

- **Endpoint:** `POST /api/v2/tools/enable_list_cleanup`
- **Token permission:** `settings`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `receipt` | `string` | Yes |  |

### update_list_cleanup_email

Replace the re-engagement email. Template is TipTap JSON. Must include {{subscribeUrl}} on a button url or link href.

- **Endpoint:** `POST /api/v2/tools/update_list_cleanup_email`
- **Token permission:** `settings`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `subject` | `string` | Yes |  |
| `preview` | `string` | No |  |
| `template` | `any` | Yes |  |

### get_tracking_domain

Read the click-tracking domain picker: every web domain with readiness (DNS/SSL), Spamhaus status, the selected domain (selectedId) and the one actually serving links (activeId). When activeId is null, links use the default Lumail tracking host. Add or verify hosts with list_web_domains / verify_web_domain.

- **Endpoint:** `POST /api/v2/tools/get_tracking_domain`
- **Token permission:** `settings`
- **In-app assistant:** every member
- **ChatGPT / Claude plugin:** read

No parameters.

### set_tracking_domain

Use a verified web domain as the click-tracking host for new emails. The domain must be VERIFIED with working HTTPS; otherwise it is refused (and demoted to PENDING if HTTPS fails). Call get_tracking_domain first.

- **Endpoint:** `POST /api/v2/tools/set_tracking_domain`
- **Token permission:** `settings`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `domainId` | `string` | Yes | The WebDomain id (wdom_...) or hostname |

### disable_tracking_domain

Stop using a custom click-tracking domain. New emails fall back to the default Lumail tracking host. The web domain itself is kept.

- **Endpoint:** `POST /api/v2/tools/disable_tracking_domain`
- **Token permission:** `settings`
- **In-app assistant:** owners and admins
- **ChatGPT / Claude plugin:** write

No parameters.

---

# Examples & Recipes
Source: https://lumail.io/docs/ai-integration/examples.md

Practical examples of what you can build with Lumail's AI integrations.

## Automated Onboarding Sequence

When a user signs up, automatically add them to Lumail with the right tags and trigger a welcome workflow:

```typescript
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY! });

export async function onUserSignup(user: {
  email: string;
  name: string;
  plan: string;
}) {
  await lumail.subscribers.create({
    email: user.email,
    name: user.name,
    tags: [user.plan, "onboarding", "new-signup"],
    fields: {
      plan: user.plan,
      signupDate: new Date().toISOString(),
    },
    triggerWorkflows: true,
  });
}
```

## Bulk Import with Progress

Import subscribers from a CSV or database with error handling:

```typescript
import { Lumail, LumailRateLimitError } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY! });

async function bulkImport(contacts: { email: string; name: string }[]) {
  const results = { success: 0, failed: 0, errors: [] as string[] };

  for (const contact of contacts) {
    try {
      await lumail.subscribers.create({
        email: contact.email,
        name: contact.name,
        tags: ["import-2025"],
      });
      results.success++;
    } catch (error) {
      if (error instanceof LumailRateLimitError) {
        await new Promise((r) => setTimeout(r, error.retryAfter ?? 2000));
        // Retry this one
        await lumail.subscribers.create({
          email: contact.email,
          name: contact.name,
          tags: ["import-2025"],
        });
        results.success++;
      } else {
        results.failed++;
        results.errors.push(`${contact.email}: ${(error as Error).message}`);
      }
    }
  }

  return results;
}
```

## E-commerce Order Tracking

Track purchases and tag customers by spending tier:

```typescript
async function trackPurchase(email: string, amount: number, product: string) {
  // Track the payment event
  await lumail.events.create({
    eventType: "SUBSCRIBER_PAYMENT",
    subscriber: email,
    data: { amount, product, currency: "USD" },
  });

  // Tag by spending tier
  const tierTag =
    amount >= 100 ? "high-value" : amount >= 50 ? "mid-value" : "starter";
  await lumail.subscribers.addTags(email, [tierTag, "customer"]);
}
```

## Scheduled Campaign Pipeline

Create and schedule campaigns programmatically:

```typescript
async function scheduleWeeklyNewsletter(content: string) {
  // Create campaign
  const { campaignId } = await lumail.campaigns.create({
    name: `Newsletter ${new Date().toISOString().slice(0, 10)}`,
    subject: "This Week at Acme",
    contentType: "MARKDOWN",
  });

  // Schedule for next Tuesday 9am UTC
  const nextTuesday = getNextDayOfWeek(2); // 0=Sun, 2=Tue
  nextTuesday.setHours(9, 0, 0, 0);

  await lumail.campaigns.send(campaignId, {
    scheduledAt: nextTuesday.toISOString(),
    timezone: "UTC",
  });

  return campaignId;
}

function getNextDayOfWeek(day: number): Date {
  const now = new Date();
  const diff = (day - now.getDay() + 7) % 7 || 7;
  return new Date(now.getTime() + diff * 86400000);
}
```

## CLI: Quick One-liners

```bash
# Export all VIP subscribers as CSV
lumail tools run list_subscribers \
  --params '{"tag": "vip", "limit": 1000}' \
  --format csv > vip-subscribers.csv

# Send a test email
lumail emails send \
  --to test@example.com \
  --from noreply@yourdomain.com \
  --subject "Test" \
  --markdown "This is a test email from the CLI"
```

> **Test-mode recipients.** Fixture addresses such as `user@example.com` never send real email. See [Test-mode recipients](/docs/api-reference/test-mode).

```bash
# Tag all subscribers from a list
cat emails.txt | while read email; do
  lumail subscribers add-tags "$email" --tags "campaign-march"
done
```

## Claude Code: Natural Language

With MCP connected, just tell Claude what to do:

```
"Import the contacts from contacts.csv into Lumail,
 tag them as 'webinar-attendees', and create a
 follow-up campaign with the subject 'Thanks for attending'"
```

```
"Show me which campaigns had the best open rate
 this month and create a similar one for next week"
```

```
"Unsubscribe all subscribers who haven't opened
 an email in the last 90 days"
```

## Related

- [TypeScript SDK Reference](/docs/api-reference/sdk)
- [CLI Reference](/docs/api-reference/cli)
- [Claude Integration](/docs/ai-integration/mcp-claude)
- [Tools API](/docs/ai-integration/tools-api)

---

# AI Integration
Source: https://lumail.io/docs/ai-integration.md

Lumail exposes a live, organization-aware tool catalog for programmatic email marketing. Use **MCP** when your assistant should call Lumail tools directly, or choose **Skills + CLI** when you want visible terminal commands and reusable workflows. The automatic setup can configure either method—or both—in one guided command.

<DocCardWrapper>

<DocSection title="Account Setup">
  <DocCardGrid>
    <DocCard
      href="/docs/ai-integration/prepare-account-2-0"
      icon="Sparkles"
      title="Prepare for Lumail 2.0.0"
      description="Agent instructions for moving marketing email to Workflows with clear branding and a reason for sending."
    />
  </DocCardGrid>
</DocSection>

<DocSection title="Use Lumail in Your AI App">
  <DocCardGrid>
    <DocCard
      href="/docs/ai-integration/mcp-claude"
      icon="Terminal"
      title="Claude Code"
      description="Install the Lumail plugin. One prompt, a browser sign-in, no API token."
    />
    <DocCard
      href="/docs/ai-integration/mcp-codex"
      icon="Terminal"
      title="Codex"
      description="Connect the Codex CLI and app over OAuth. No token in config.toml."
    />
    <DocCard
      href="/docs/ai-integration/chatgpt-plugin"
      icon="Bot"
      title="ChatGPT"
      description="Connect Lumail as an OAuth app without copying an API token."
    />
    <DocCard
      href="/docs/ai-integration/hermes"
      icon="Terminal"
      title="Hermes"
      description="Install the native Lumail skill and manage email from Hermes."
    />
    <DocCard
      href="/docs/ai-integration/openclaw"
      icon="Bot"
      title="OpenClaw"
      description="Install Lumail from ClawHub and use it through OpenClaw."
    />
  </DocCardGrid>
</DocSection>

<DocSection title="Developer Integrations">
  <DocCardGrid>
    <DocCard
      href="/docs/ai-integration/skills-cli"
      icon="Terminal"
      title="Skills (CLI)"
      description="Automatically install the Lumail skill and CLI for terminal-based AI workflows."
    />
    <DocCard
      href="/docs/ai-integration/mcp-cursor"
      icon="Bot"
      title="Cursor (MCP)"
      description="Add Lumail tools to Cursor for AI-assisted email marketing."
    />
    <DocCard
      href="/docs/ai-integration/sdk-agents"
      icon="Code2"
      title="TypeScript SDK"
      description="Integrate Lumail into web apps, backends, scripts, and product automations."
    />
  </DocCardGrid>
</DocSection>

<DocSection title="API & Tools">
  <DocCardGrid>
    <DocCard
      href="/docs/ai-integration/tools-api"
      icon="Settings"
      title="Tools API for AI Agents"
      description="Discover and call the current tool catalog through one REST endpoint."
    />
    <DocCard
      href="/docs/api-reference/cli"
      icon="FileText"
      title="CLI"
      description="Run tools from your terminal with the Lumail CLI."
    />
    <DocCard
      href="/docs/ai-integration/examples"
      icon="Sparkles"
      title="Examples & Recipes"
      description="Real-world assistant workflows and automation patterns."
    />
  </DocCardGrid>
</DocSection>

</DocCardWrapper>

## Which Method Should I Use?

| Method                              | Best For                                            | Setup Time |
| ----------------------------------- | --------------------------------------------------- | ---------- |
| **OAuth app (ChatGPT/Codex)**       | Safe interactive assistant workflows                | 2 minutes  |
| **MCP (Claude/Cursor)**             | API-token interactive assistant workflows           | 2 minutes  |
| **Skills (Hermes/OpenClaw/Claude)** | Agent-driven terminal workflows                     | 2 minutes  |
| **TypeScript SDK**                  | Web apps, SaaS backends, product automations, CI/CD | 5 minutes  |
| **Tools API (REST)**                | Any language, webhooks, serverless functions        | 5 minutes  |
| **CLI**                             | Terminal workflows, scripts, quick operations       | 1 minute   |

## Available Tools

The exact catalog depends on the client safety profile. Tools are discovered dynamically across these categories:

| Category        | Examples                                                            |
| --------------- | ------------------------------------------------------------------- |
| **Subscribers** | Create, query, count, unsubscribe, and manage tags                  |
| **Campaigns**   | Draft, edit, render, test, schedule, inspect history, and analyze   |
| **Emails**      | Send transactional email and inspect deliverability                 |
| **Tags**        | Create, list, delete, and resolve by name                           |
| **Workflows**   | Configure complete graphs, publish versions, manage runs and groups |
| **Segments**    | Create, list, inspect, update, duplicate, and delete                |
| **Analytics**   | Organization, campaign, and subscriber performance                  |
| **Settings**    | Writing style, senders, snippets, fields, and organization settings |
| **Domains**     | Create, verify, and inspect DNS records                             |
| **Assets**      | Upload, list, describe, import, and export                          |

---

# SDK Introduction
Source: https://lumail.io/docs/sdk/introduction.md

The Lumail TypeScript SDK is the easiest way to integrate the Lumail API into a JavaScript or TypeScript project. It gives you a typed client for subscribers, campaigns, transactional emails, tags, events, and Lumail tools.

It is built for web apps, SaaS backends, serverless functions, internal dashboards, and scripts. It is not a separate AI-agent runtime. If you are connecting Claude, Cursor, or another assistant directly to Lumail, use the [MCP integration](/docs/ai-integration/mcp-claude). If you need language-agnostic access, use the [REST API](/docs/api-reference) or [Tools API](/docs/ai-integration/tools-api).

## Install

```bash
pnpm add lumail
```

## Quick Start

```typescript
import { Lumail } from "lumail";

const lumail = new Lumail({
  apiKey: process.env.LUMAIL_API_KEY!,
});

const { subscriber } = await lumail.subscribers.create({
  email: "user@example.com",
  name: "Jane Doe",
  tags: ["newsletter"],
  triggerWorkflows: true,
});

await lumail.events.create({
  eventType: "SUBSCRIBED",
  subscriber: subscriber.email,
  data: { source: "signup-form" },
});
```

## Common Integration Points

| Integration point    | What the SDK does                                          |
| -------------------- | ---------------------------------------------------------- |
| Signup forms         | Create or update subscribers and trigger workflows         |
| Checkout flows       | Tag customers, store plan fields, and track payment events |
| Product events       | Record subscriber activity for segmentation and analytics  |
| Transactional email  | Send email from a verified domain                          |
| Internal admin tools | Search, update, tag, or unsubscribe subscribers            |
| Scheduled jobs       | Create, schedule, and send campaigns from backend code     |

## Choose the Right Lumail Interface

| Interface      | Best for                                                      |
| -------------- | ------------------------------------------------------------- |
| TypeScript SDK | JavaScript or TypeScript apps that want typed API access      |
| REST API       | Any language or framework                                     |
| Tools API      | Calling Lumail tool actions through one endpoint              |
| MCP            | Letting AI assistants discover and call Lumail tools directly |
| CLI            | Terminal workflows and shell scripts                          |

## Next Steps

- [Configuration](/docs/sdk/configuration) - create the client and manage API keys
- [Resources](/docs/sdk/resources) - subscribers, campaigns, emails, tags, and events
- [Tools](/docs/sdk/tools) - call Lumail tools from your app
- [Errors and Retries](/docs/sdk/errors-retries) - handle typed errors and rate limits

---

# SDK 2.1.0
Source: https://lumail.io/docs/sdk/v2/introduction.md

`npm i lumail@2.1.0`. Base URL `https://lumail.io/api`. Bearer `lum_` token. Methods return `{ data, error }` — no `try/catch` for ordinary API errors. Missing API key still throws at construction.

```typescript
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY! });

const { data, error } = await lumail.emails.send(
  {
    from: "hello@yourdomain.com",
    to: "user@example.com",
    subject: "Welcome",
    html: "<p>Hello</p>",
  },
  { idempotencyKey: "welcome-1" },
);
```

> **Test-mode recipients.** Fixture addresses such as `user@example.com` never send real email. See [Test-mode recipients](/docs/api-reference/test-mode).

Network, timeout, and abort errors use `error.name` of `network_error`, `timeout`, or `aborted` and do not invent an HTTP `statusCode`.

Optional `baseUrl` for self-hosted or beta.

## Resources

- [`lumail.emails`](/docs/sdk/v2/emails)
- [`lumail.subscribers`](/docs/sdk/v2/subscribers)
- [`lumail.domains`](/docs/sdk/v2/domains)
- [`lumail.campaigns`](/docs/sdk/v2/campaigns)
- [`lumail.tags`](/docs/sdk/v2/tags)
- [`lumail.events`](/docs/sdk/v2/events)
- [`lumail.tokens`](/docs/sdk/v2/tokens)
- [`lumail.tools`](/docs/sdk/v2/tools)

v1 HTTP stays frozen. New code uses v2. Rewrite guide: [Migration to V2](/docs/api-reference/v2/migration). v2 send accepts `html`, `tiptap`, or `markdown`.

---

# SDK Configuration
Source: https://lumail.io/docs/sdk/configuration.md

Create a Lumail client once in server-side code and reuse it wherever you call the API.

```typescript
import { Lumail } from "lumail";

export const lumail = new Lumail({
  apiKey: process.env.LUMAIL_API_KEY!,
});
```

## API Key

Create an API token in **Settings > API Tokens** in your Lumail dashboard. Store it as a server-side environment variable.

```bash
LUMAIL_API_KEY=lum_your_api_token_here
```

Never expose this token in browser code. Call Lumail from a server component, route handler, server action, serverless function, job worker, or backend service.

## Base URL

The SDK defaults to the production API. Set `baseUrl` only when you are targeting another Lumail environment.

```typescript
const lumail = new Lumail({
  apiKey: process.env.LUMAIL_API_KEY!,
  baseUrl: "https://lumail.io/api",
});
```

## Runtime Placement

Use the SDK in trusted server-side code:

```typescript
// app/api/signup/route.ts
import { lumail } from "@/lib/lumail";

export async function POST(request: Request) {
  const body = await request.json();

  const { subscriber } = await lumail.subscribers.create({
    email: body.email,
    tags: ["signup"],
    triggerWorkflows: true,
  });

  return Response.json({ subscriber });
}
```

If you are adding a route to this codebase, follow the project API route conventions and use the existing route validation helpers.

## Related

- [API Tokens](/docs/api-reference/api-tokens)
- [SDK Introduction](/docs/sdk/introduction)
- [Errors and Retries](/docs/sdk/errors-retries)

---

# SDK Resources
Source: https://lumail.io/docs/sdk/resources.md

The SDK groups API operations by resource. Each resource client maps to common Lumail workflows.

## Subscribers

Create or update a subscriber and optionally trigger matching workflows.

```typescript
const { subscriber } = await lumail.subscribers.create({
  email: "user@example.com",
  name: "Jane Doe",
  tags: ["newsletter", "customer"],
  fields: { plan: "pro" },
  triggerWorkflows: true,
  skipDoubleOptIn: true,
});
```

Update subscriber fields or replace tags later:

```typescript
await lumail.subscribers.update("user@example.com", {
  fields: { plan: "enterprise" },
  tags: ["enterprise"],
  replaceTags: true,
});
```

## Campaigns

Create campaigns, fetch details, update drafts, and send immediately or on a schedule.

```typescript
const { campaignId } = await lumail.campaigns.create({
  subject: "Welcome to the newsletter",
  name: "Welcome Campaign",
  preview: "Here is what to expect.",
  contentType: "MARKDOWN",
});

await lumail.campaigns.send(campaignId, {
  scheduledAt: "2026-06-01T09:00:00Z",
  timezone: "Europe/Paris",
});
```

## Transactional Emails

Send transactional email from a verified domain.

```typescript
await lumail.emails.send({
  to: "user@example.com",
  from: "hello@yourdomain.com",
  subject: "Your receipt",
  markdown: "Thanks for your purchase.",
});
```

> **Test-mode recipients.** Fixture addresses such as `user@example.com` never send real email. See [Test-mode recipients](/docs/api-reference/test-mode).

To send a React Email template, render it to HTML and pass `html` - see [Send React Email Templates with Lumail](/docs/tutorials/react-email).

## Email Verification

Validate an address before storing or sending to it.

```typescript
const { data, error } = await lumail.verify("test@example.com");

if (error) throw error;
if (!data.accept) {
  console.log(data.issues[0]?.code, data.suggestion);
}
```

## Tags

List, create, fetch, and rename tags.

```typescript
const { tags } = await lumail.tags.list();
const { tag } = await lumail.tags.create({ name: "vip" });
await lumail.tags.update(tag.id, { name: "customer" });
```

## Events

Track product activity and business events on a subscriber profile.

```typescript
await lumail.events.create({
  eventType: "SUBSCRIBER_PAYMENT",
  subscriber: "user@example.com",
  data: {
    amount: 99,
    currency: "USD",
    plan: "pro",
  },
});
```

## Related

- [Full SDK Reference](/docs/api-reference/sdk)
- [Subscriber Events](/docs/features/subscriber-events)
- [Verify](/docs/api-reference/v2/verify)

---

# SDK Tools
Source: https://lumail.io/docs/sdk/tools.md

The SDK can list, inspect, and run Lumail tools through the V2 tools API.

This is useful when your app wants one generic tool interface for actions like dashboard stats, subscriber search, campaign operations, or AI-assisted copy improvements. It is still a normal API call from your application. For direct assistant integrations, use [MCP](/docs/ai-integration/mcp-claude).

## List Tools

```typescript
const { tools, grouped } = await lumail.tools.list();

console.log(tools.length);
console.log(Object.keys(grouped));
```

## Get a Tool Schema

```typescript
const { tool } = await lumail.tools.get("list_subscribers");

console.log(tool.inputSchema);
```

## Run a Tool

```typescript
const result = await lumail.tools.run<{ subscribers: unknown[] }>(
  "list_subscribers",
  {
    limit: 10,
    status: "SUBSCRIBED",
  },
);
```

## When to Use SDK Tools

| Scenario                                                  | Use SDK tools?         |
| --------------------------------------------------------- | ---------------------- |
| Your backend needs a generic action runner                | Yes                    |
| You want to expose selected Lumail actions in an admin UI | Yes                    |
| You are writing TypeScript automation scripts             | Yes                    |
| You are connecting Claude or Cursor directly              | Use MCP                |
| You need Python, Ruby, Go, or PHP                         | Use the REST Tools API |

## Related

- [Tools API](/docs/ai-integration/tools-api)
- [V2 Tools Reference](/docs/api-reference/v2-tools)
- [MCP for Claude](/docs/ai-integration/mcp-claude)

---

# lumail.tools
Source: https://lumail.io/docs/sdk/v2/tools.md

`lumail@2.1.0` talks to `/api/v2/tools`.

Same operations as MCP, over HTTP.

---

# Errors and Retries
Source: https://lumail.io/docs/sdk/errors-retries.md

The SDK throws typed errors for HTTP failures so your application can branch on the error class instead of parsing raw responses.

```typescript
import {
  LumailAuthenticationError,
  LumailNotFoundError,
  LumailRateLimitError,
  LumailValidationError,
} from "lumail";

try {
  await lumail.subscribers.get("unknown@example.com");
} catch (error) {
  if (error instanceof LumailNotFoundError) {
    console.log("Subscriber not found");
  } else if (error instanceof LumailRateLimitError) {
    console.log(`Retry after ${error.retryAfter}ms`);
  } else if (error instanceof LumailAuthenticationError) {
    console.log("Invalid API key");
  } else if (error instanceof LumailValidationError) {
    console.log(error.message);
  } else {
    throw error;
  }
}
```

## Error Classes

| Error                        | Meaning                                              |
| ---------------------------- | ---------------------------------------------------- |
| `LumailValidationError`      | Request body or parameters are invalid               |
| `LumailAuthenticationError`  | API token is missing or invalid                      |
| `LumailPaymentRequiredError` | Plan limit or billing requirement blocks the request |
| `LumailNotFoundError`        | Requested resource does not exist                    |
| `LumailRateLimitError`       | Request was rate limited                             |
| `LumailError`                | Other Lumail API error                               |

## Retry Behavior

| Method                 | Retries | Why                                                |
| ---------------------- | ------- | -------------------------------------------------- |
| `GET`, `PUT`, `DELETE` | Up to 3 | Safer to retry after network errors or rate limits |
| `POST`, `PATCH`        | None    | Avoids duplicate writes                            |

Retry delays use exponential backoff: 1s, 2s, and 4s. Rate limit responses can include `Retry-After`; the SDK exposes it on `LumailRateLimitError.retryAfter`.

## Idempotent Recovery Pattern

```typescript
import { LumailNotFoundError } from "lumail";

export async function upsertSubscriber(
  email: string,
  fields: Record<string, string>,
) {
  try {
    return await lumail.subscribers.update(email, { fields });
  } catch (error) {
    if (error instanceof LumailNotFoundError) {
      return lumail.subscribers.create({ email, fields });
    }

    throw error;
  }
}
```

## Related

- [API Limits](/docs/api-reference/api-limits)
- [SDK Resources](/docs/sdk/resources)
- [Full SDK Reference](/docs/api-reference/sdk)

---

# lumail.emails
Source: https://lumail.io/docs/sdk/v2/emails.md

`lumail@2.1.0` talks to `/api/v2/emails`.

Transactional send path. Provide exactly one of `html`, `tiptap`, or `markdown`. Markdown is rendered to HTML.

---

# lumail.verify
Source: https://lumail.io/docs/sdk/v2/verify.md

```typescript
lumail.verify(email | { email: string }): Promise<LumailResult<EmailVerification>>
```

```typescript
const { data, error } = await lumail.verify("user@example.com");

if (error) throw error;
if (!data.accept) {
  console.log(data.issues[0]?.code);
}
const next = data.suggestion ?? data.email;
```

## Body Parameters

| Name    | Type   | Description                                                     |
| ------- | ------ | --------------------------------------------------------------- |
| `email` | string | **Required.** Address to check. Also accepted as a bare string. |

## Response Fields

| Field        | Type                   | Description                                                                                                |
| ------------ | ---------------------- | ---------------------------------------------------------------------------------------------------------- |
| `object`     | `"email_verification"` | Resource type.                                                                                             |
| `id`         | string                 | Tenant-owned `verify_result` id.                                                                           |
| `email`      | string                 | Normalized address that was checked.                                                                       |
| `result`     | string                 | `deliverable`, `risky`, `undeliverable`, or `unknown`.                                                     |
| `accept`     | boolean                | Policy decision. Can be `true` when quota is exhausted.                                                    |
| `status`     | string                 | `completed`, `skipped`, or `failed`.                                                                       |
| `score`      | integer \| null        | 0–100 deliverability confidence.                                                                           |
| `suggestion` | string \| null         | Better address when the domain looks like a typo.                                                          |
| `traits`     | object                 | Domain category plus disposable, free-provider, privacy-alias, role-based, and suspicious-domain booleans. |
| `issues`     | object[]               | Errors and warnings only. Empty when the address is clean.                                                 |
| `quota`      | object                 | `{ limit, used, remaining, resets_at }` for the UTC month.                                                 |
| `retry_at`   | string \| null         | ISO timestamp when a retryable check should be tried again.                                                |
| `cache_hit`  | boolean                | `true` when the result was served from cache.                                                              |

Field details: [API reference](/docs/api-reference/v2/verify).

_Legacy `lumail.emails.verify()` still talks to `/api/v2/emails/verify`._


## API Reference
**Method:** POST
**Endpoint:** /api/v2/verify

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.verify("user@example.com");
if (error) {
  throw error;
}
if (!data.accept) {
  throw data.issues[0];
}
console.log(data.result, data.suggestion);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/verify \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com"}'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/verify", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ email: "user@example.com" }),
});
const data = await response.json();
```

### Success Response
```json
{
  "object": "email_verification",
  "id": "vr_abc123",
  "email": "user@example.com",
  "result": "risky",
  "accept": true,
  "status": "completed",
  "score": 80,
  "suggestion": null,
  "traits": {
    "domain_category": "custom_domain",
    "disposable": false,
    "free_provider": false,
    "privacy_alias": false,
    "role_based": false,
    "suspicious_domain": false
  },
  "issues": [],
  "retry_at": null,
  "cache_hit": false
}
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "email is required",
  "statusCode": 422
}
```

---

# lumail.emails.send
Source: https://lumail.io/docs/sdk/v2/emails-send.md

`lumail@2.1.0` · [`lumail.emails`](/docs/sdk/v2/emails)

```typescript
emails.send(params: SendEmailParams, options?: { idempotencyKey?: string }): Promise<LumailResult<{ id: string }>>
```

Queues one transactional email. Exactly one of `html`, `tiptap`, or `markdown`. Unsupported: `attachments`, `template`, `topic_id`, `scheduled_at`, `react`, `content`, `contentType`.

> **Test-mode recipients.** Fixture addresses such as `user@example.com` never send real email. See [Test-mode recipients](/docs/api-reference/test-mode).
>
> **Recipient rate guards.** The same real mailbox is capped at 5 sends / 10 minutes and 20 / 24 hours. A 429 with `code: RECIPIENT_RATE_LIMITED` is not the HTTP API rpm limit. See [Recipient rate guards](/docs/deliverability/recipient-rate-guards).

## Body Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `from` | string | **Required.** Sender. Display name is allowed: `Acme <hello@yourdomain.com>`. Domain must be verified. |
| `to` | string or `[string]` | **Required.** One recipient. A one-address array is accepted. |
| `subject` | string | **Required.** Subject line. |
| `html` | string | HTML body. Provide exactly one of `html`, `tiptap`, or `markdown`. |
| `tiptap` | string or object | TipTap JSON. Rendered to HTML on send. |
| `markdown` | string | Markdown body. Rendered to HTML on send. |
| `text` | string | Plain-text body. Omit to generate from HTML. Empty string skips generation. |
| `reply_to` | string or string[] | Reply-To addresses. Stored as an array. SES uses the first address. |
| `cc` | string or string[] | Carbon copies. |
| `bcc` | string or string[] | Blind carbon copies. |
| `headers` | object | Extra MIME headers. |
| `tags` | `{ name, value }[]` | Metadata tags stored on the email. |
| `preview` | string | Preview / preheader text. |
| `tracking` | `{ links: boolean, open: boolean }` | Open pixel and click wrapping. Both default to `true`. Set both to `false` for OTP / magic-link mail. |

## Options

| Name | Type | Description |
| ---- | ---- | ----------- |
| `idempotencyKey` | string | Second-argument option. Mapped to the `Idempotency-Key` header. Repeat the same key to reuse the queued email id. |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | string | Queued email id (`eml_...`). Fetch the body and `last_event` with [`emails.get`](/docs/sdk/v2/emails-get). |

`data` is `{ id }`. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** POST
**Endpoint:** /api/v2/emails

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.emails.send({
  from: "hello@yourdomain.com",
  to: "user@example.com",
  subject: "Welcome",
  html: "<p>Hello</p>",
});
if (error) {
  throw error;
}
console.log(data.id);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/emails \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "hello@yourdomain.com",
    "to": "user@example.com",
    "subject": "Welcome",
    "html": "<p>Hello</p>"
  }'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/emails", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    from: "hello@yourdomain.com",
    to: "user@example.com",
    subject: "Welcome",
    html: "<p>Hello</p>",
  }),
});
const data = await response.json();
```

### Success Response
```json
{ "id": "eml_abc123def456" }
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "Provide exactly one of html, tiptap, or markdown",
  "statusCode": 422
}
```

---

# lumail.emails.batch
Source: https://lumail.io/docs/sdk/v2/emails-batch.md

`lumail@2.1.0` · [`lumail.emails`](/docs/sdk/v2/emails)

```typescript
emails.batch(emails: SendEmailParams[], options?: { idempotencyKey?: string }): Promise<LumailResult<{ data: { id: string }[] }>>
```

Up to 100 sends. Each item matches [`emails.send`](/docs/sdk/v2/emails-send). HTTP body is the JSON array.

> **Test-mode recipients.** Fixture addresses such as `user@example.com` never send real email. See [Test-mode recipients](/docs/api-reference/test-mode).
>
> **Recipient rate guards.** Each real `to` shares the 5/10 min and 20/24 h caps. See [Recipient rate guards](/docs/deliverability/recipient-rate-guards).

## Body Parameters

| Name | Type | Description |
| -------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `emails` | `SendEmailParams[]` | **Required.** First argument. Array of send payloads. Max 100. Each item: `from`, `to`, `subject`, and exactly one of `html`, `tiptap`, or `markdown`. Also accepts `text`, `reply_to`, `cc`, `bcc`, `headers`, `tags`, `preview`. |

## Options

| Name | Type | Description |
| ---------------- | ------ | ------------------------------------------------------------------------------------------------------ |
| `idempotencyKey` | string | Second-argument option. Mapped to the `Idempotency-Key` header. The server suffixes `:index` per item. |

## Response Fields

| Field | Type | Description |
| ------ | ------------------ | -------------------------------------------- |
| `data` | `{ id: string }[]` | Queued email ids, same order as the request. |

`data` is `{ data: [{ id }] }`. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** POST
**Endpoint:** /api/v2/emails/batch

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.emails.batch(
  [
    { from: "hello@yourdomain.com", to: "a@example.com", subject: "A", html: "<p>A</p>" },
    { from: "hello@yourdomain.com", to: "b@example.com", subject: "B", html: "<p>B</p>" },
  ],
  { idempotencyKey: "welcome-batch-1" },
);
if (error) {
  throw error;
}
console.log(data.data.map((email) => email.id));
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/emails/batch \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: welcome-batch-1" \
  -d '[
    {"from":"hello@yourdomain.com","to":"a@example.com","subject":"A","html":"<p>A</p>"},
    {"from":"hello@yourdomain.com","to":"b@example.com","subject":"B","html":"<p>B</p>"}
  ]'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/emails/batch", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
    "Idempotency-Key": "welcome-batch-1",
  },
  body: JSON.stringify([
    { from: "hello@yourdomain.com", to: "a@example.com", subject: "A", html: "<p>A</p>" },
    { from: "hello@yourdomain.com", to: "b@example.com", subject: "B", html: "<p>B</p>" },
  ]),
});
const data = await response.json();
```

### Success Response
```json
{ "data": [{ "id": "eml_..." }, { "id": "eml_..." }] }
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "Batch payload must be an array of emails",
  "statusCode": 422
}
```

---

# lumail.emails.get
Source: https://lumail.io/docs/sdk/v2/emails-get.md

`lumail@2.1.0` · [`lumail.emails`](/docs/sdk/v2/emails)

```typescript
emails.get(id: string): Promise<LumailResult<EmailObject>>
```

One queued or sent transactional email, including `last_event`. List rows omit `html`, `text`, and `tags`.

## Path Parameters

| Name | Type | Description |
| ---- | ------ | ----------------------------------- |
| `id` | string | **Required.** Email id (`eml_...`). |

## Response Fields

| Field | Type | Description |
| -------------- | ------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------- |
| `object` | `"email"` or `"list"` | Discriminator. Always `email` on this endpoint. |
| `id` | string | Lumail email id. |
| `message_id` | string or null | Provider message id once accepted. `null` while queued. |
| `from` | string | Envelope From, including display name when you sent one. |
| `to` | string[] | Recipients. v2 send accepts one address. |
| `subject` | string | Subject line. |
| `html` | string or null | Rendered HTML body. |
| `text` | string or null | Plain-text body. |
| `bcc` | string[] or null | Blind carbon copies from send, otherwise `null`. |
| `cc` | string[] or null | Carbon copies from send, otherwise `null`. |
| `reply_to` | string[] or null | Reply-To addresses from send, otherwise `null`. |
| `last_event` | `"queued"` / `"sent"` / `"delivered"` / `"opened"` / `"clicked"` / `"bounced"` / `"complained"` / `"failed"` | Latest delivery or tracking event. |
| `scheduled_at` | `null` | Always `null`. Scheduled send is not supported. |
| `tags` | `{ name, value }[]` | Tags from send. Empty array when none. |
| `created_at` | string | ISO 8601 timestamp. |

`data` is the email object. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** GET
**Endpoint:** /api/v2/emails/:id

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.emails.get("eml_abc123def456");
if (error) {
  throw error;
}
console.log(data.last_event);
console.log(data.html);
```

### cURL
```bash
curl https://lumail.io/api/v2/emails/eml_abc123def456 \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch(
  "https://lumail.io/api/v2/emails/eml_abc123def456",
  {
    headers: {
      Authorization: "Bearer YOUR_API_TOKEN",
    },
  },
);
const data = await response.json();
```

### Success Response
```json
{
  "object": "email",
  "id": "eml_abc123def456",
  "message_id": "0100018f3c2a1b2c-d4e5f6a7-89ab-cdef-0123-456789abcdef-000000",
  "to": ["user@example.com"],
  "from": "hello@yourdomain.com",
  "created_at": "2026-09-05T10:30:00.000Z",
  "subject": "Welcome",
  "html": "<p>Hello</p>",
  "text": "Hello",
  "last_event": "delivered",
  "tags": []
}
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Email not found",
  "statusCode": 404
}
```

---

# lumail.emails.list
Source: https://lumail.io/docs/sdk/v2/emails-list.md

`lumail@2.1.0` · [`lumail.emails`](/docs/sdk/v2/emails)

```typescript
emails.list(params?: { limit?: number; after?: string; before?: string }): Promise<LumailResult<ListEmailsResponse>>
```

Newest first. Paginate with `after` or `before` email ids. Campaign sends and test emails are excluded.

## Query Parameters

Pass `after` or `before`, not both. The cursor must be an email id in this organization.

| Name | Type | Description |
| -------- | ------ | ----------------------------------------------------------------------- |
| `limit` | number | Page size. Default `20`. Min `1`, max `100`. |
| `after` | string | Return emails older than this id (next page, walking backward in time). |
| `before` | string | Return emails newer than this id. |

## Response Fields

| Field | Type | Description |
| ---------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `object` | `"list"` | Discriminator. Always `list`. |
| `has_more` | boolean | `true` when another page exists in the same direction. |
| `data` | `EmailObject[]` | Email rows, newest first. Same fields as [`emails.get`](/docs/sdk/v2/emails-get) except `object`, `html`, `text`, and `tags`. |

`data` is `{ object, data, has_more }`. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** GET
**Endpoint:** /api/v2/emails

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.emails.list({ limit: 20 });
if (error) {
  throw error;
}

for (const email of data.data) {
  console.log(email.id, email.last_event);
}

if (data.has_more) {
  const next = await lumail.emails.list({
    limit: 20,
    after: data.data.at(-1)?.id,
  });
  console.log(next.data?.data.length);
}
```

### cURL
```bash
curl "https://lumail.io/api/v2/emails?limit=20" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

curl "https://lumail.io/api/v2/emails?limit=20&after=eml_abc123def456" \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/emails?limit=20", {
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
  },
});
const page = await response.json();

const next = await fetch(
  `https://lumail.io/api/v2/emails?limit=20&after=${page.data.at(-1).id}`,
  { headers: { Authorization: "Bearer YOUR_API_TOKEN" } },
);
await next.json();
```

### Success Response
```json
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "eml_abc123def456",
      "message_id": "0100018f3c2a1b2c-d4e5f6a7-89ab-cdef-0123-456789abcdef-000000",
      "to": ["user@example.com"],
      "from": "hello@yourdomain.com",
      "created_at": "2026-09-05T10:30:00.000Z",
      "subject": "Welcome",
      "last_event": "delivered"
    }
  ]
}
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "Use either after or before, not both",
  "statusCode": 422
}
```

---

# lumail.subscribers
Source: https://lumail.io/docs/sdk/v2/subscribers.md

`lumail@2.1.0` talks to `/api/v2/subscribers`.

Contacts. `:id` arguments accept a subscriber id or email.

---

# lumail.subscribers.list
Source: https://lumail.io/docs/sdk/v2/subscribers-list.md

`lumail@2.1.0` · [`lumail.subscribers`](/docs/sdk/v2/subscribers)

```typescript
subscribers.list(params?: ListSubscribersParams): Promise<LumailResult<ListSubscribersResponse>>
```

Filter with `query`, `status`, `tag`, `limit`, `after`, `before`.

## Query Parameters

`SubscriberStatus`: `PENDING_CONFIRMATION`, `SUBSCRIBED`, `UNSUBSCRIBED`, `BOUNCED`, `BANNED`, `COMPLAINED`, `TRANSACTIONAL`.

| Name | Type | Description |
| -------- | ----------------------------- | ------------------------------------------------------------- |
| `limit` | number | Page size. |
| `after` | string | Cursor: return contacts after this id. |
| `before` | string | Cursor: return contacts before this id. Forwarded by the SDK. |
| `tag` | string | Filter by tag name. |
| `status` | `SubscriberStatus` or `"all"` | Filter by status. `all` returns every status. |
| `query` | string | Search string (email or name). |

## Response Fields

| Field | Type | Description |
| ---------- | ----------------------- | --------------------------------------------------------------------------------------- |
| `object` | `"list"` | Discriminator. Always `list`. |
| `has_more` | boolean | `true` when another page exists. |
| `data` | `ListSubscriberEntry[]` | Contacts, newest first. Each row: `id`, `email`, `name`, `status`, `createdAt`, `tags`. |

`data` is `{ object, data, has_more }`. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** GET
**Endpoint:** /api/v2/subscribers

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.subscribers.list({
  limit: 50,
  query: "ada@",
});
if (error) {
  throw error;
}
for (const subscriber of data.data) {
  console.log(subscriber.email, subscriber.status);
}
```

### cURL
```bash
curl "https://lumail.io/api/v2/subscribers?limit=50&query=ada%40" \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch(
  "https://lumail.io/api/v2/subscribers?limit=50&query=ada%40",
  {
    headers: {
      Authorization: "Bearer YOUR_API_TOKEN",
    },
  },
);
const data = await response.json();
```

### Success Response
```json
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "sub_abc123",
      "email": "ada@example.com",
      "name": "Ada",
      "status": "SUBSCRIBED",
      "createdAt": "2026-09-05T10:30:00.000Z",
      "tags": [{ "id": "tag_abc", "name": "newsletter" }]
    }
  ]
}
```

### Error Response
```json
{
  "name": "missing_api_key",
  "message": "Missing API key",
  "statusCode": 401
}
```

---

# lumail.subscribers.create
Source: https://lumail.io/docs/sdk/v2/subscribers-create.md

`lumail@2.1.0` · [`lumail.subscribers`](/docs/sdk/v2/subscribers)

```typescript
subscribers.create(params: CreateSubscriberParams): Promise<LumailResult<CreateSubscriberResponse>>
```

`email` required. Upserts by email.

`skipDoubleOptIn` defaults to `false`. Pass `true` from a trusted backend (checkout, server-side import) to create the contact as `SUBSCRIBED` even when the organization has double opt-in enabled. This does not record a native confirmation click (`confirmedAt` stays empty). Do not use it on public forms.

## Body Parameters

| Name | Type | Description |
| ------------------ | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `email` | string | **Required.** Contact email. |
| `name` | string or null | Display name. |
| `phone` | string or null | Phone number. |
| `tags` | string[] | Tag names to attach. Created if missing. |
| `fields` | `Record` | Custom fields. `null` clears a field. |
| `replaceTags` | boolean | When `true`, replace the existing tag set with `tags`. |
| `resubscribe` | boolean | When `true`, re-subscribe an `UNSUBSCRIBED` contact. |
| `triggerWorkflows` | boolean | When `true`, run subscribe workflows. |
| `skipDoubleOptIn` | boolean | When `true`, create as `SUBSCRIBED` even if org double opt-in is on. Does not resubscribe `UNSUBSCRIBED` unless `resubscribe` is also `true`. |
| `ipAddress` | string | Optional signup IP. |
| `country` | string | Optional country code. |

## Response Fields

| Field | Type | Description |
| ----------- | ------------------ | ---------------------------- |
| `id` | string | Subscriber id. |
| `email` | string | Email. |
| `name` | string or null | Display name. |
| `phone` | string or null | Phone. |
| `status` | `SubscriberStatus` | Current status. |
| `tags` | `{ id, name }[]` | Attached tags. |
| `fields` | `Record` | Custom fields. |
| `createdAt` | string | Optional ISO 8601 timestamp. |
| `updatedAt` | string | Optional ISO 8601 timestamp. |
| `warnings` | string[] | Optional non-fatal notes. |

`data` is the subscriber. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** POST
**Endpoint:** /api/v2/subscribers

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.subscribers.create({
  email: "user@example.com",
  name: "Ada",
  tags: ["newsletter"],
});
if (error) {
  throw error;
}
console.log(data.id);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/subscribers \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com","name":"Ada","tags":["newsletter"]}'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/subscribers", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    email: "user@example.com",
    name: "Ada",
    tags: ["newsletter"],
  }),
});
const data = await response.json();
```

### Success Response
```json
{
  "id": "sub_abc123",
  "email": "user@example.com",
  "name": "Ada",
  "phone": null,
  "status": "SUBSCRIBED",
  "tags": [{ "id": "tag_abc", "name": "newsletter" }],
  "fields": {}
}
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "email is required",
  "statusCode": 422
}
```

---

# lumail.subscribers.get
Source: https://lumail.io/docs/sdk/v2/subscribers-get.md

`lumail@2.1.0` · [`lumail.subscribers`](/docs/sdk/v2/subscribers)

```typescript
subscribers.get(idOrEmail: string): Promise<LumailResult<GetSubscriberResponse>>
```

Id or email.

## Path Parameters

| Name | Type | Description |
| ----------- | ------ | ------------------------------------- |
| `idOrEmail` | string | **Required.** Subscriber id or email. |

## Response Fields

| Field | Type | Description |
| ----------- | ------------------ | ---------------------------- |
| `id` | string | Subscriber id. |
| `email` | string | Email. |
| `name` | string or null | Display name. |
| `phone` | string or null | Phone. |
| `status` | `SubscriberStatus` | Current status. |
| `tags` | `{ id, name }[]` | Attached tags. |
| `fields` | `Record` | Custom fields. |
| `createdAt` | string | Optional ISO 8601 timestamp. |
| `updatedAt` | string | Optional ISO 8601 timestamp. |

`data` is the subscriber. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** GET
**Endpoint:** /api/v2/subscribers/:subscriber

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.subscribers.get("user@example.com");
if (error) {
  throw error;
}
console.log(data.id, data.status);
```

### cURL
```bash
curl https://lumail.io/api/v2/subscribers/user@example.com \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/subscribers/user@example.com", {
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
  },
});
const data = await response.json();
```

### Success Response
```json
{
  "id": "sub_abc123",
  "email": "user@example.com",
  "name": "Ada",
  "phone": null,
  "status": "SUBSCRIBED",
  "tags": [{ "id": "tag_abc", "name": "newsletter" }],
  "fields": {}
}
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Subscriber not found",
  "statusCode": 404
}
```

---

# lumail.subscribers.update
Source: https://lumail.io/docs/sdk/v2/subscribers-update.md

`lumail@2.1.0` · [`lumail.subscribers`](/docs/sdk/v2/subscribers)

```typescript
subscribers.update(idOrEmail: string, params: UpdateSubscriberParams): Promise<LumailResult<UpdateSubscriberResponse>>
```

Patch name, fields, tags. Optional `replaceTags`, `resubscribe`, `triggerWorkflows`, `skipDoubleOptIn`.

`skipDoubleOptIn: true` promotes an existing `PENDING_CONFIRMATION` contact to `SUBSCRIBED` before tag events. It does not resubscribe `UNSUBSCRIBED` unless `resubscribe` is also `true`.

## Path Parameters

| Name | Type | Description |
| ----------- | ------ | ------------------------------------- |
| `idOrEmail` | string | **Required.** Subscriber id or email. |

## Body Parameters

| Name | Type | Description |
| ------------------ | -------------- | ------------------------------------------------------------------------------------------------- |
| `email` | string | New email address. |
| `name` | string or null | Display name. |
| `phone` | string or null | Phone number. |
| `tags` | string[] | Tag names to attach. |
| `fields` | `Record` | Custom fields. `null` clears a field. |
| `replaceTags` | boolean | When `true`, replace the existing tag set with `tags`. |
| `resubscribe` | boolean | When `true`, re-subscribe an `UNSUBSCRIBED` contact. Omitted `resubscribe` does not re-subscribe. |
| `triggerWorkflows` | boolean | When `true`, run subscribe workflows. |
| `skipDoubleOptIn` | boolean | When `true`, promote `PENDING_CONFIRMATION` to `SUBSCRIBED` before tag events. |
| `ipAddress` | string | Optional signup IP. |
| `country` | string | Optional country code. |

## Response Fields

| Field | Type | Description |
| ----------- | ------------------ | ---------------------------- |
| `id` | string | Subscriber id. |
| `email` | string | Email. |
| `name` | string or null | Display name. |
| `phone` | string or null | Phone. |
| `status` | `SubscriberStatus` | Current status. |
| `tags` | `{ id, name }[]` | Attached tags. |
| `fields` | `Record` | Custom fields. |
| `createdAt` | string | Optional ISO 8601 timestamp. |
| `updatedAt` | string | Optional ISO 8601 timestamp. |
| `warnings` | string[] | Optional non-fatal notes. |

`data` is the subscriber. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** PATCH
**Endpoint:** /api/v2/subscribers/:subscriber

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.subscribers.update(
  "user@example.com",
  { name: "Ada Lovelace" },
);
if (error) {
  throw error;
}
console.log(data.name);
```

### cURL
```bash
curl -X PATCH https://lumail.io/api/v2/subscribers/user@example.com \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Ada Lovelace"}'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/subscribers/user@example.com", {
  method: "PATCH",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ name: "Ada Lovelace" }),
});
const data = await response.json();
```

### Success Response
```json
{
  "id": "sub_abc123",
  "email": "user@example.com",
  "name": "Ada Lovelace",
  "phone": null,
  "status": "SUBSCRIBED",
  "tags": [],
  "fields": {}
}
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Subscriber not found",
  "statusCode": 404
}
```

---

# lumail.subscribers.delete
Source: https://lumail.io/docs/sdk/v2/subscribers-delete.md

`lumail@2.1.0` · [`lumail.subscribers`](/docs/sdk/v2/subscribers)

```typescript
subscribers.delete(idOrEmail: string): Promise<LumailResult<DeleteSubscriberResponse>>
```

Disabled, same policy as v1. The server returns 422. Use [`unsubscribe`](/docs/sdk/v2/subscribers-unsubscribe).

## Path Parameters

| Name | Type | Description |
| ----------- | ------ | ------------------------------------- |
| `idOrEmail` | string | **Required.** Subscriber id or email. |

## Response Fields

| Field | Type | Description |
| --------- | ------ | --------------------------------------------------------------- |
| `object` | string | Resource name. Typed as `"subscriber"` if delete ever succeeds. |
| `id` | string | Subscriber id. |
| `deleted` | `true` | Always `true` on the typed success shape. |

This method does not delete the contact. `error` is `{ name: "validation_error", message, statusCode: 422 }`.


## API Reference
**Method:** DELETE
**Endpoint:** /api/v2/subscribers/:subscriber

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.subscribers.delete("user@example.com");
if (error) {
  throw error;
}
console.log(data.id, data.deleted);
```

### cURL
```bash
curl -X DELETE https://lumail.io/api/v2/subscribers/user@example.com \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/subscribers/user@example.com", {
  method: "DELETE",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
  },
});
const data = await response.json();
```

### Success Response
```json
{
  "object": "subscriber",
  "id": "sub_abc123",
  "deleted": true
}
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "Subscriber deletion is disabled. Use POST /api/v2/subscribers/{subscriber}/unsubscribe.",
  "statusCode": 422
}
```

---

# lumail.subscribers.unsubscribe
Source: https://lumail.io/docs/sdk/v2/subscribers-unsubscribe.md

`lumail@2.1.0` · [`lumail.subscribers`](/docs/sdk/v2/subscribers)

```typescript
subscribers.unsubscribe(idOrEmail: string): Promise<LumailResult<UnsubscribeResponse>>
```

Marks the contact unsubscribed.

## Path Parameters

| Name | Type | Description |
| ----------- | ------ | ------------------------------------- |
| `idOrEmail` | string | **Required.** Subscriber id or email. |

## Response Fields

| Field | Type | Description |
| --------- | ------------------ | ---------------------------- |
| `id` | string | Subscriber id when returned. |
| `email` | string | Email when returned. |
| `status` | `SubscriberStatus` | Usually `UNSUBSCRIBED`. |
| `message` | string | Human-readable confirmation. |

`data` is the unsubscribe payload. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** POST
**Endpoint:** /api/v2/subscribers/:subscriber/unsubscribe

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.subscribers.unsubscribe(
  "user@example.com",
);
if (error) {
  throw error;
}
console.log(data.status ?? data.message);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/subscribers/user@example.com/unsubscribe \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/subscribers/user@example.com/unsubscribe", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
  },
});
const data = await response.json();
```

### Success Response
```json
{
  "id": "sub_abc123",
  "email": "user@example.com",
  "status": "UNSUBSCRIBED",
  "message": "Subscriber unsubscribed successfully"
}
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Subscriber not found",
  "statusCode": 404
}
```

---

# lumail.subscribers.addTags
Source: https://lumail.io/docs/sdk/v2/subscribers-add-tags.md

`lumail@2.1.0` · [`lumail.subscribers`](/docs/sdk/v2/subscribers)

```typescript
subscribers.addTags(idOrEmail: string, tags: string[]): Promise<LumailResult<AddTagsResponse>>
```

Attach tags without replacing the rest.

## Path Parameters

| Name | Type | Description |
| ----------- | ------ | ------------------------------------- |
| `idOrEmail` | string | **Required.** Subscriber id or email. |

## Body Parameters

| Name | Type | Description |
| ------ | -------- | ----------------------------------------------------------------------- |
| `tags` | string[] | **Required.** Second argument. Tag names to attach. Sent as `{ tags }`. |

## Response Fields

| Field | Type | Description |
| ------- | ---------------- | ------------------------ |
| `added` | number | Count of tags attached. |
| `tags` | `{ id, name }[]` | Resulting or added tags. |

`data` is `{ added?, tags? }`. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** POST
**Endpoint:** /api/v2/subscribers/:subscriber/tags

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.subscribers.addTags(
  "user@example.com",
  ["vip"],
);
if (error) {
  throw error;
}
console.log(data.added ?? data.tags);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/subscribers/user@example.com/tags \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"tags":["vip"]}'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/subscribers/user@example.com/tags", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ tags: ["vip"] }),
});
const data = await response.json();
```

### Success Response
```json
{
  "added": 1,
  "tags": [{ "id": "tag_abc", "name": "vip" }]
}
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Subscriber not found",
  "statusCode": 404
}
```

---

# lumail.subscribers.removeTags
Source: https://lumail.io/docs/sdk/v2/subscribers-remove-tags.md

`lumail@2.1.0` · [`lumail.subscribers`](/docs/sdk/v2/subscribers)

```typescript
subscribers.removeTags(idOrEmail: string, tags: string[]): Promise<LumailResult<RemoveTagsResponse>>
```

Detach tags. The SDK sends `tags` as a JSON-string query param.

## Path Parameters

| Name | Type | Description |
| ----------- | ------ | ------------------------------------- |
| `idOrEmail` | string | **Required.** Subscriber id or email. |

## Query Parameters

| Name | Type | Description |
| ------ | -------- | --------------------------------------------------------------------------- |
| `tags` | string[] | **Required.** Second argument. Tag names to detach. Serialized as `?tags=`. |

## Response Fields

| Field | Type | Description |
| --------- | ---------------- | ----------------------------- |
| `removed` | number | Count of tags detached. |
| `tags` | `{ id, name }[]` | Remaining tags when returned. |
| `message` | string | Optional confirmation. |

`data` is `{ removed?, tags?, message? }`. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** DELETE
**Endpoint:** /api/v2/subscribers/:subscriber/tags

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.subscribers.removeTags(
  "user@example.com",
  ["vip"],
);
if (error) {
  throw error;
}
console.log(data.removed ?? data.message);
```

### cURL
```bash
curl -X DELETE "https://lumail.io/api/v2/subscribers/user@example.com/tags?tags=%5B%22vip%22%5D" \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const tags = encodeURIComponent(JSON.stringify(["vip"]));
const response = await fetch(
  `https://lumail.io/api/v2/subscribers/user@example.com/tags?tags=${tags}`,
  {
    method: "DELETE",
    headers: {
      Authorization: "Bearer YOUR_API_TOKEN",
    },
  },
);
const data = await response.json();
```

### Success Response
```json
{
  "removed": 1,
  "tags": [{ "id": "tag_abc", "name": "newsletter" }],
  "message": "Tags removed"
}
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "tags must be a JSON array of strings",
  "statusCode": 422
}
```

---

# lumail.subscribers.listEvents
Source: https://lumail.io/docs/sdk/v2/subscribers-list-events.md

`lumail@2.1.0` · [`lumail.subscribers`](/docs/sdk/v2/subscribers)

```typescript
subscribers.listEvents(idOrEmail: string, params?: ListEventsParams): Promise<LumailResult<ListEventsResponse>>
```

Events recorded on one contact. The SDK forwards `limit`, `after`, `before`, and `eventTypes` (JSON-stringified). `order`, `startDate`, `endDate`, and `search` are typed on `ListEventsParams` but are not forwarded.

## Path Parameters

| Name | Type | Description |
| ----------- | ------ | ------------------------------------- |
| `idOrEmail` | string | **Required.** Subscriber id or email. |

## Query Parameters

| Name | Type | Description |
| ------------ | ----------------------- | ------------------------------------------------- |
| `limit` | number | Page size. Forwarded. |
| `after` | string | Cursor: older events. Forwarded. |
| `before` | string | Cursor: newer events. Forwarded by the SDK. |
| `eventTypes` | `SubscriberEventType[]` | Filter by event type. Forwarded as a JSON string. |
| `order` | `"asc"` or `"desc"` | Typed only. Not forwarded by the SDK. |
| `startDate` | string | Typed only. Not forwarded by the SDK. |
| `endDate` | string | Typed only. Not forwarded by the SDK. |
| `search` | string | Typed only. Not forwarded by the SDK. |

## Response Fields

| Field | Type | Description |
| ---------- | ------------------- | --------------------------------------------------------------------------------------- |
| `object` | `"list"` | Discriminator. Always `list`. |
| `has_more` | boolean | `true` when another page exists. |
| `data` | `SubscriberEvent[]` | Events. Each row: `id`, `eventType`, `data`, `subscriberId`, `createdAt`, `subscriber`. |

`data` is `{ object, data, has_more }`. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** GET
**Endpoint:** /api/v2/subscribers/:subscriber/events

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.subscribers.listEvents(
  "user@example.com",
  { limit: 20, eventTypes: ["EMAIL_OPENED", "EMAIL_CLICKED"] },
);
if (error) {
  throw error;
}
for (const event of data.data) {
  console.log(event.eventType, event.createdAt);
}
```

### cURL
```bash
curl "https://lumail.io/api/v2/subscribers/user@example.com/events?limit=20" \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch(
  "https://lumail.io/api/v2/subscribers/user@example.com/events?limit=20",
  {
    headers: {
      Authorization: "Bearer YOUR_API_TOKEN",
    },
  },
);
const data = await response.json();
```

### Success Response
```json
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "evt_abc123",
      "eventType": "EMAIL_OPENED",
      "data": null,
      "subscriberId": "sub_abc123",
      "createdAt": "2026-09-05T10:30:00.000Z",
      "subscriber": {
        "id": "sub_abc123",
        "email": "user@example.com",
        "name": "Ada"
      }
    }
  ]
}
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Subscriber not found",
  "statusCode": 404
}
```

---

# lumail.domains
Source: https://lumail.io/docs/sdk/v2/domains.md

`lumail@2.1.0` talks to `/api/v2/domains`.

Sending domains. Arguments accept a domain id or hostname.

---

# lumail.domains.list
Source: https://lumail.io/docs/sdk/v2/domains-list.md

`lumail@2.1.0` · [`lumail.domains`](/docs/sdk/v2/domains)

```typescript
domains.list(): Promise<LumailResult<ListDomainsResponse>>
```

Permission: `domains`. The SDK takes no arguments and does not forward `?limit=`. The HTTP route may still accept `limit` if you call it with fetch.

## Parameters

No parameters.

## Response Fields

| Field | Type | Description |
| ---------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `object` | `"list"` | Discriminator. Always `list`. |
| `has_more` | boolean | `true` when the HTTP `limit` slice hid more rows. The SDK never sends `limit`, so this is usually `false`. |
| `data` | `DomainRecord[]` | Sending domains. Each row: `id`, `domain`, `status`, optional `region`, `createdAt`, `sesRecords`, `dnsRecords`, `message`. |

`data` is `{ object, data, has_more }`. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** GET
**Endpoint:** /api/v2/domains

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.domains.list();
if (error) {
  throw error;
}
for (const domain of data.data) {
  console.log(domain.domain, domain.status);
}
```

### cURL
```bash
curl https://lumail.io/api/v2/domains \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/domains", {
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
  },
});
const data = await response.json();
```

### Success Response
```json
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "dom_abc123",
      "domain": "mail.example.com",
      "status": "VERIFIED"
    }
  ]
}
```

### Error Response
```json
{
  "name": "missing_api_key",
  "message": "Missing API key",
  "statusCode": 401
}
```

---

# lumail.domains.create
Source: https://lumail.io/docs/sdk/v2/domains-create.md

`lumail@2.1.0` · [`lumail.domains`](/docs/sdk/v2/domains)

```typescript
domains.create({ domain: string }): Promise<LumailResult<DomainRecord>>
```

Registers the domain and returns DNS records.

## Body Parameters

| Name | Type | Description |
| -------- | ------ | ------------------------------------------------------- |
| `domain` | string | **Required.** Hostname to register as a sending domain. |

## Response Fields

| Field | Type | Description |
| ------------ | --------- | ------------------------------------- |
| `id` | string | Domain id. |
| `domain` | string | Hostname. |
| `status` | string | Usually `PENDING` until DNS verifies. |
| `region` | string | Optional SES region. |
| `createdAt` | string | Optional ISO 8601 timestamp. |
| `sesRecords` | unknown | Optional provider DNS payload. |
| `dnsRecords` | unknown[] | Records to publish at your DNS host. |
| `message` | string | Optional status note. |

`data` is the domain record. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** POST
**Endpoint:** /api/v2/domains

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.domains.create({
  domain: "mail.example.com",
});
if (error) {
  throw error;
}
console.log(data.id, data.dnsRecords);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/domains \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"domain":"mail.example.com"}'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/domains", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ domain: "mail.example.com" }),
});
const data = await response.json();
```

### Success Response
```json
{
  "id": "dom_abc123",
  "domain": "mail.example.com",
  "status": "PENDING",
  "dnsRecords": [
    { "type": "CNAME", "name": "abc._domainkey.mail.example.com", "value": "abc.dkim.amazonses.com" }
  ]
}
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "Domain already registered",
  "statusCode": 422
}
```

---

# lumail.domains.get
Source: https://lumail.io/docs/sdk/v2/domains-get.md

`lumail@2.1.0` · [`lumail.domains`](/docs/sdk/v2/domains)

```typescript
domains.get(domainOrId: string): Promise<LumailResult<DomainRecord>>
```

Id or hostname.

## Path Parameters

| Name | Type | Description |
| ------------ | ------ | ---------------------------------------- |
| `domainOrId` | string | **Required.** MailDomain id or hostname. |

## Response Fields

| Field | Type | Description |
| ------------ | --------- | ----------------------------------------------- |
| `id` | string | Domain id. |
| `domain` | string | Hostname. |
| `status` | string | Verification status (`PENDING`, `VERIFIED`, …). |
| `region` | string | Optional SES region. |
| `createdAt` | string | Optional ISO 8601 timestamp. |
| `sesRecords` | unknown | Optional provider DNS payload. |
| `dnsRecords` | unknown[] | Optional DNS records to publish. |
| `message` | string | Optional status note. |

`data` is the domain record. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** GET
**Endpoint:** /api/v2/domains/:domain

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.domains.get("mail.example.com");
if (error) {
  throw error;
}
console.log(data.status);
```

### cURL
```bash
curl https://lumail.io/api/v2/domains/mail.example.com \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/domains/mail.example.com", {
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
  },
});
const data = await response.json();
```

### Success Response
```json
{
  "id": "dom_abc123",
  "domain": "mail.example.com",
  "status": "VERIFIED"
}
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Domain not found",
  "statusCode": 404
}
```

---

# lumail.domains.verify
Source: https://lumail.io/docs/sdk/v2/domains-verify.md

`lumail@2.1.0` · [`lumail.domains`](/docs/sdk/v2/domains)

```typescript
domains.verify(domainOrId: string): Promise<LumailResult<VerifyDomainResponse>>
```

Checks DNS. Id or hostname.

## Path Parameters

| Name | Type | Description |
| ------------ | ------ | ---------------------------------------- |
| `domainOrId` | string | **Required.** MailDomain id or hostname. |

## Response Fields

| Field | Type | Description |
| ------------ | --------- | ---------------------------------------------------- |
| `id` | string | Domain id. |
| `status` | string | Status after the check (`VERIFIED`, `PENDING`, …). |
| `dnsRecords` | unknown[] | Remaining or current DNS records when still pending. |

`data` is `{ id, status, dnsRecords? }`. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** POST
**Endpoint:** /api/v2/domains/:domain/verify

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.domains.verify("mail.example.com");
if (error) {
  throw error;
}
console.log(data.status);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/domains/mail.example.com/verify \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/domains/mail.example.com/verify", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
  },
});
const data = await response.json();
```

### Success Response
```json
{
  "id": "dom_abc123",
  "status": "VERIFIED"
}
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Domain not found",
  "statusCode": 404
}
```

---

# lumail.campaigns
Source: https://lumail.io/docs/sdk/v2/campaigns.md

`lumail@2.1.0` talks to `/api/v2/campaigns`.

Broadcast campaigns.

---

# lumail.campaigns.list
Source: https://lumail.io/docs/sdk/v2/campaigns-list.md

`lumail@2.1.0` · [`lumail.campaigns`](/docs/sdk/v2/campaigns)

```typescript
campaigns.list(params?: ListCampaignsParams): Promise<ListCampaignsResponse>
```

Newest first. `status: "all"` is dropped by the SDK and not sent.

## Query Parameters

| Name | Type | Description |
| -------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `limit` | number | Page size. |
| `after` | string | Cursor: older campaigns. |
| `before` | string | Cursor: newer campaigns. |
| `status` | `"all"` / `"DRAFT"` / `"ARCHIVED"` / `"SCHEDULED"` / `"SENDING"` / `"SENT"` / `"FAILED"` | Filter by status. `"all"` is omitted from the request. |
| `query` | string | Search by name or subject. |

## Response Fields

| Field | Type | Description |
| ---------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `object` | `"list"` | Discriminator. Always `list`. |
| `has_more` | boolean | `true` when another page exists. |
| `data` | `CampaignListItem[]` | Campaigns. Each row: `id`, `name`, `subject`, `status`, `recipientCount`, `emailsSentCount`, `emailsOpenedCount`, `emailsClickedCount`, `scheduledAt`, `sendAt`. |

`data` is `{ object, data, has_more }`. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** GET
**Endpoint:** /api/v2/campaigns

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.campaigns.list({
  limit: 20,
  status: "DRAFT",
});
if (error) {
  throw error;
}
for (const campaign of data.data) {
  console.log(campaign.id, campaign.subject);
}
```

### cURL
```bash
curl "https://lumail.io/api/v2/campaigns?limit=20&status=DRAFT" \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch(
  "https://lumail.io/api/v2/campaigns?limit=20&status=DRAFT",
  {
    headers: {
      Authorization: "Bearer YOUR_API_TOKEN",
    },
  },
);
const data = await response.json();
```

### Success Response
```json
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "cmp_abc123",
      "name": "Launch",
      "subject": "Hello",
      "status": "DRAFT",
      "recipientCount": 0,
      "emailsSentCount": 0,
      "emailsOpenedCount": 0,
      "emailsClickedCount": 0,
      "scheduledAt": null,
      "sendAt": null
    }
  ]
}
```

### Error Response
```json
{
  "name": "missing_api_key",
  "message": "Missing API key",
  "statusCode": 401
}
```

---

# lumail.campaigns.create
Source: https://lumail.io/docs/sdk/v2/campaigns-create.md

`lumail@2.1.0` · [`lumail.campaigns`](/docs/sdk/v2/campaigns)

```typescript
campaigns.create(params: CreateCampaignParams): Promise<LumailResult<CreateCampaignResponse>>
```

Creates a draft.

## Body Parameters

| Name | Type | Description |
| ------------- | ------------------------------------ | ---------------------------------------------------- |
| `subject` | string | **Required.** Subject line. |
| `name` | string | Internal name. Defaults from `subject` when omitted. |
| `preview` | string or null | Preview / preheader text. |
| `content` | unknown | MAILY: TipTap JSON. HTML: raw HTML string with `<a href="{{unsubscribeUrl}}">Unsubscribe</a>`. |
| `contentType` | `"MAILY"` / `"HTML"` | Editor format. Rendering does not add a footer. |
| `senderId` | string | Sending identity id. |
| `replyTo` | string or null | Reply-To address. |

## Response Fields

| Field | Type | Description |
| ------------- | ---------------- | ---------------------------- |
| `id` | string | Campaign id. |
| `name` | string | Internal name. |
| `subject` | string | Subject line. |
| `preview` | string or null | Preview text. |
| `status` | `CampaignStatus` | Usually `DRAFT`. |
| `contentType` | `ContentType` | Editor format. |
| `senderId` | string | Sending identity id. |
| `replyTo` | string or null | Reply-To address. |
| `createdAt` | string | Optional ISO 8601 timestamp. |

`data` is the campaign write result. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** POST
**Endpoint:** /api/v2/campaigns

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.campaigns.create({
  name: "Launch",
  subject: "Hello",
});
if (error) {
  throw error;
}
console.log(data.id, data.status);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/campaigns \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Launch","subject":"Hello"}'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/campaigns", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ name: "Launch", subject: "Hello" }),
});
const data = await response.json();
```

### Success Response
```json
{
  "id": "cmp_abc123",
  "name": "Launch",
  "subject": "Hello",
  "preview": null,
  "status": "DRAFT",
  "contentType": "MAILY",
  "senderId": "snd_abc123",
  "replyTo": null
}
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "subject is required",
  "statusCode": 422
}
```

---

# lumail.campaigns.get
Source: https://lumail.io/docs/sdk/v2/campaigns-get.md

`lumail@2.1.0` · [`lumail.campaigns`](/docs/sdk/v2/campaigns)

```typescript
campaigns.get(campaignId: string): Promise<LumailResult<GetCampaignResponse>>
```

One campaign.

## Path Parameters

| Name | Type | Description |
| ------------ | ------ | -------------------------- |
| `campaignId` | string | **Required.** Campaign id. |

## Response Fields

| Field | Type | Description |
| -------------------- | ---------------- | ----------------------------------------------------------------------------- |
| `id` | string | Campaign id. |
| `campaignId` | string | Optional alias of `id`. |
| `name` | string | Internal name. |
| `subject` | string | Subject line. |
| `preview` | string or null | Preview text. |
| `status` | `CampaignStatus` | `DRAFT`, `ARCHIVED`, `SCHEDULED`, `SENDING`, `SENT`, or `FAILED`. |
| `contentType` | `ContentType` | Editor format. |
| `sendAt` | string or null | Actual send time. |
| `scheduledAt` | string or null | Scheduled send time. |
| `recipientCount` | number | Audience size. |
| `emailsSentCount` | number | Sent count. |
| `emailsOpenedCount` | number | Open count. |
| `emailsClickedCount` | number | Click count. |
| `createdAt` | string | Optional ISO 8601 timestamp. |
| `updatedAt` | string | Optional ISO 8601 timestamp. |
| `content` | unknown | Campaign body. |
| `replyTo` | string or null | Reply-To address. |
| `senderId` | string | Sending identity id. |
| `timezone` | string or null | Schedule timezone. |
| `recipientFilters` | unknown[] | Audience filters. |
| `sender` | `CampaignSender` | Sender: `id`, `name`, `displayName`, `localPart`, `replyTo`, `domain.domain`. |

`data` is the campaign. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** GET
**Endpoint:** /api/v2/campaigns/:campaignId

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.campaigns.get("cmp_abc123");
if (error) {
  throw error;
}
console.log(data.subject, data.status);
```

### cURL
```bash
curl https://lumail.io/api/v2/campaigns/cmp_abc123 \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/campaigns/cmp_abc123", {
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
  },
});
const data = await response.json();
```

### Success Response
```json
{
  "id": "cmp_abc123",
  "name": "Launch",
  "subject": "Hello",
  "preview": null,
  "status": "DRAFT",
  "contentType": "MAILY",
  "senderId": "snd_abc123",
  "replyTo": null,
  "timezone": null,
  "content": { "type": "doc", "content": [] },
  "recipientFilters": [],
  "sender": {
    "id": "snd_abc123",
    "name": "Hello",
    "displayName": "Hello",
    "localPart": "hello",
    "replyTo": null,
    "domain": { "domain": "yourdomain.com" }
  }
}
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Campaign not found",
  "statusCode": 404
}
```

---

# lumail.campaigns.update
Source: https://lumail.io/docs/sdk/v2/campaigns-update.md

`lumail@2.1.0` · [`lumail.campaigns`](/docs/sdk/v2/campaigns)

```typescript
campaigns.update(campaignId: string, params: UpdateCampaignParams): Promise<LumailResult<UpdateCampaignResponse>>
```

Patch a draft. Locked on `SCHEDULED`, `SENDING`, `SENT`, and workflow campaigns.

## Path Parameters

| Name | Type | Description |
| ------------ | ------ | -------------------------- |
| `campaignId` | string | **Required.** Campaign id. |

## Body Parameters

| Name | Type | Description |
| ------------- | ------------------------------------ | ------------------------- |
| `subject` | string | Subject line. |
| `name` | string | Internal name. |
| `preview` | string or null | Preview / preheader text. |
| `content` | unknown | MAILY: TipTap JSON. HTML: raw HTML string. Switching format requires both `contentType` and `content`. |
| `contentType` | `"MAILY"` / `"HTML"` | Editor format. Metadata-only updates never switch format. |
| `senderId` | string | Sending identity id. |
| `replyTo` | string or null | Reply-To address. |

## Response Fields

All body fields are optional (`Partial<CreateCampaignParams>`).

| Field | Type | Description |
| --------------- | ---------------- | ------------------------- |
| `id` | string | Campaign id. |
| `name` | string | Internal name. |
| `subject` | string | Subject line. |
| `preview` | string or null | Preview text. |
| `status` | `CampaignStatus` | Current status. |
| `contentType` | `ContentType` | Editor format. |
| `senderId` | string | Sending identity id. |
| `replyTo` | string or null | Reply-To address. |
| `updatedFields` | string[] | Fields that changed. |
| `warnings` | string[] | Optional non-fatal notes. |

`data` is the patched campaign. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** PATCH
**Endpoint:** /api/v2/campaigns/:campaignId

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.campaigns.update("cmp_abc123", {
  subject: "Updated subject",
});
if (error) {
  throw error;
}
console.log(data.subject);
```

### cURL
```bash
curl -X PATCH https://lumail.io/api/v2/campaigns/cmp_abc123 \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"subject":"Updated subject"}'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/campaigns/cmp_abc123", {
  method: "PATCH",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ subject: "Updated subject" }),
});
const data = await response.json();
```

### Success Response
```json
{
  "id": "cmp_abc123",
  "name": "Launch",
  "subject": "Updated subject",
  "preview": null,
  "status": "DRAFT",
  "contentType": "MAILY",
  "senderId": "snd_abc123",
  "replyTo": null,
  "updatedFields": ["subject"]
}
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "Campaign cannot be patched in this status",
  "statusCode": 422
}
```

---

# lumail.campaigns.delete
Source: https://lumail.io/docs/sdk/v2/campaigns-delete.md

`lumail@2.1.0` · [`lumail.campaigns`](/docs/sdk/v2/campaigns)

```typescript
campaigns.delete(campaignId: string): Promise<LumailResult<DeleteCampaignResponse>>
```

Deletes the campaign.

## Path Parameters

| Name | Type | Description |
| ------------ | ------ | -------------------------- |
| `campaignId` | string | **Required.** Campaign id. |

## Response Fields

| Field | Type | Description |
| --------- | ------ | --------------------------- |
| `object` | string | Resource name (`campaign`). |
| `id` | string | Deleted campaign id. |
| `deleted` | `true` | Always `true` on success. |

`data` is `{ object, id, deleted: true }`. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** DELETE
**Endpoint:** /api/v2/campaigns/:campaignId

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.campaigns.delete("cmp_abc123");
if (error) {
  throw error;
}
console.log(data.deleted, data.id);
```

### cURL
```bash
curl -X DELETE https://lumail.io/api/v2/campaigns/cmp_abc123 \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/campaigns/cmp_abc123", {
  method: "DELETE",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
  },
});
const data = await response.json();
```

### Success Response
```json
{
  "object": "campaign",
  "id": "cmp_abc123",
  "deleted": true
}
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Campaign not found",
  "statusCode": 404
}
```

---

# lumail.campaigns.send
Source: https://lumail.io/docs/sdk/v2/campaigns-send.md

`lumail@2.1.0` · [`lumail.campaigns`](/docs/sdk/v2/campaigns)

```typescript
campaigns.send(campaignId: string, params?: SendCampaignParams): Promise<LumailResult<SendCampaignResponse>>
```

No date sends immediately. Optional `scheduledAt` and `timezone`. The SDK sends `{}` when `params` is omitted.

## Path Parameters

| Name | Type | Description |
| ------------ | ------ | -------------------------- |
| `campaignId` | string | **Required.** Campaign id. |

## Body Parameters

| Name | Type | Description |
| ------------- | ------ | ------------------------------------------------------------------- |
| `scheduledAt` | string | ISO 8601 datetime. Omit to send immediately. Must be in the future. |
| `timezone` | string | IANA timezone for the schedule. |

## Response Fields

| Field | Type | Description |
| ------------- | ------------------------------------ | ------------------------ |
| `id` | string | Campaign id. |
| `campaignId` | string | Optional alias of `id`. |
| `status` | `"SCHEDULED"` / `"SENDING"` / string | New status. |
| `scheduledAt` | string | Scheduled time when set. |
| `timezone` | string | Timezone when set. |

`data` is the send result. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** POST
**Endpoint:** /api/v2/campaigns/:campaignId/send

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.campaigns.send("cmp_abc123", {
  scheduledAt: "2026-06-01T09:00:00Z",
  timezone: "Europe/Paris",
});
if (error) {
  throw error;
}
console.log(data.status, data.scheduledAt);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/campaigns/cmp_abc123/send \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"scheduledAt":"2026-06-01T09:00:00Z","timezone":"Europe/Paris"}'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/campaigns/cmp_abc123/send", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    scheduledAt: "2026-06-01T09:00:00Z",
    timezone: "Europe/Paris",
  }),
});
const data = await response.json();
```

### Success Response
```json
{
  "id": "cmp_abc123",
  "status": "SCHEDULED",
  "scheduledAt": "2026-06-01T09:00:00.000Z",
  "timezone": "Europe/Paris"
}
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "Campaign must include an unsubscribe link",
  "statusCode": 422
}
```

---

# lumail.tags
Source: https://lumail.io/docs/sdk/v2/tags.md

`lumail@2.1.0` talks to `/api/v2/tags`.

Audience tags.

---

# lumail.tags.list
Source: https://lumail.io/docs/sdk/v2/tags-list.md

`lumail@2.1.0` · [`lumail.tags`](/docs/sdk/v2/tags)

```typescript
tags.list(): Promise<LumailResult<ListTagsResponse>>
```

All tags. The SDK takes no arguments and does not forward `?limit=`. The HTTP route may still accept `limit` if you call it with fetch.

## Parameters

No parameters.

## Response Fields

| Field | Type | Description |
| ---------- | ---------------- | ---------------------------------------------------------------------------------------------------------- |
| `object` | `"list"` | Discriminator. Always `list`. |
| `has_more` | boolean | `true` when the HTTP `limit` slice hid more rows. The SDK never sends `limit`, so this is usually `false`. |
| `data` | `{ id, name }[]` | Audience tags. |

`data` is `{ object, data, has_more }`. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** GET
**Endpoint:** /api/v2/tags

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.tags.list();
if (error) {
  throw error;
}
for (const tag of data.data) {
  console.log(tag.id, tag.name);
}
```

### cURL
```bash
curl https://lumail.io/api/v2/tags \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/tags", {
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
  },
});
const data = await response.json();
```

### Success Response
```json
{
  "object": "list",
  "has_more": false,
  "data": [
    { "id": "tag_abc123", "name": "vip" }
  ]
}
```

### Error Response
```json
{
  "name": "missing_api_key",
  "message": "Missing API key",
  "statusCode": 401
}
```

---

# lumail.tags.create
Source: https://lumail.io/docs/sdk/v2/tags-create.md

`lumail@2.1.0` · [`lumail.tags`](/docs/sdk/v2/tags)

```typescript
tags.create({ name: string }): Promise<LumailResult<CreateTagResponse>>
```

Create a tag.

## Body Parameters

| Name | Type | Description |
| ------ | ------ | ----------------------- |
| `name` | string | **Required.** Tag name. |

## Response Fields

| Field | Type | Description |
| ------ | ------ | ----------- |
| `id` | string | Tag id. |
| `name` | string | Tag name. |

`data` is `{ id, name }`. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** POST
**Endpoint:** /api/v2/tags

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.tags.create({ name: "vip" });
if (error) {
  throw error;
}
console.log(data.id, data.name);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/tags \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"vip"}'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/tags", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ name: "vip" }),
});
const data = await response.json();
```

### Success Response
```json
{ "id": "tag_abc123", "name": "vip" }
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "name is required",
  "statusCode": 422
}
```

---

# lumail.tags.get
Source: https://lumail.io/docs/sdk/v2/tags-get.md

`lumail@2.1.0` · [`lumail.tags`](/docs/sdk/v2/tags)

```typescript
tags.get(idOrName: string): Promise<LumailResult<GetTagResponse>>
```

Id or name.

## Path Parameters

| Name | Type | Description |
| ---------- | ------ | ----------------------------- |
| `idOrName` | string | **Required.** Tag id or name. |

## Response Fields

| Field | Type | Description |
| ------------------ | ------ | -------------------------- |
| `id` | string | Tag id. |
| `name` | string | Tag name. |
| `subscribersCount` | number | Contacts currently tagged. |

`data` is `{ id, name, subscribersCount }`. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** GET
**Endpoint:** /api/v2/tags/:tag

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.tags.get("vip");
if (error) {
  throw error;
}
console.log(data.name, data.subscribersCount);
```

### cURL
```bash
curl https://lumail.io/api/v2/tags/vip \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/tags/vip", {
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
  },
});
const data = await response.json();
```

### Success Response
```json
{
  "id": "tag_abc123",
  "name": "vip",
  "subscribersCount": 12
}
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Tag not found",
  "statusCode": 404
}
```

---

# lumail.tags.update
Source: https://lumail.io/docs/sdk/v2/tags-update.md

`lumail@2.1.0` · [`lumail.tags`](/docs/sdk/v2/tags)

```typescript
tags.update(idOrName: string, params: { name: string }): Promise<LumailResult<UpdateTagResponse>>
```

Rename.

## Path Parameters

| Name | Type | Description |
| ---------- | ------ | ------------------------------------- |
| `idOrName` | string | **Required.** Current tag id or name. |

## Body Parameters

| Name | Type | Description |
| ------ | ------ | --------------------------- |
| `name` | string | **Required.** New tag name. |

## Response Fields

| Field | Type | Description |
| ------ | ------ | ----------- |
| `id` | string | Tag id. |
| `name` | string | New name. |

`data` is `{ id, name }`. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** PATCH
**Endpoint:** /api/v2/tags/:tag

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.tags.update("vip", {
  name: "customer",
});
if (error) {
  throw error;
}
console.log(data.name);
```

### cURL
```bash
curl -X PATCH https://lumail.io/api/v2/tags/vip \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"customer"}'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/tags/vip", {
  method: "PATCH",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ name: "customer" }),
});
const data = await response.json();
```

### Success Response
```json
{ "id": "tag_abc123", "name": "customer" }
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Tag not found",
  "statusCode": 404
}
```

---

# lumail.events
Source: https://lumail.io/docs/sdk/v2/events.md

`lumail@2.1.0` talks to `/api/v2/events`.

Payment and refund events on a subscriber.

---

# lumail.events.create
Source: https://lumail.io/docs/sdk/v2/events-create.md

`lumail@2.1.0` · [`lumail.events`](/docs/sdk/v2/events)

```typescript
events.create(params: CreateEventParams): Promise<LumailResult<CreateEventResponse>>
```

`eventType` is `SUBSCRIBER_PAYMENT` or `SUBSCRIBER_REFUND`. `subscriber` is an email or id.

## Body Parameters

| Name | Type | Description |
| ------------ | --------------------- | ---------------------------------------------------------------- |
| `eventType` | `SubscriberEventType` | **Required.** Use `SUBSCRIBER_PAYMENT` or `SUBSCRIBER_REFUND`. |
| `subscriber` | string | **Required.** Subscriber email or id. |
| `data` | `Record` | **Required.** Event payload. Typical keys: `amount`, `currency`. |

## Response Fields

| Field | Type | Description |
| ---------------- | --------------------- | ---------------------------- |
| `id` | string | Created event id. |
| `eventType` | `SubscriberEventType` | Stored type. |
| `data` | `Record` | Optional stored payload. |
| `subscriberId` | string | Optional subscriber id. |
| `organizationId` | string | Optional organization id. |
| `createdAt` | string | Optional ISO 8601 timestamp. |

HTTP maps to `{ id, eventType }`. `data` is that object. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** POST
**Endpoint:** /api/v2/events

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.events.create({
  eventType: "SUBSCRIBER_PAYMENT",
  subscriber: "user@example.com",
  data: { amount: 2000, currency: "usd" },
});
if (error) {
  throw error;
}
console.log(data.id, data.eventType);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/events \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"eventType":"SUBSCRIBER_PAYMENT","subscriber":"user@example.com","data":{"amount":2000,"currency":"usd"}}'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/events", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    eventType: "SUBSCRIBER_PAYMENT",
    subscriber: "user@example.com",
    data: { amount: 2000, currency: "usd" },
  }),
});
const data = await response.json();
```

### Success Response
```json
{
  "id": "evt_abc123",
  "eventType": "SUBSCRIBER_PAYMENT"
}
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "eventType must be SUBSCRIBER_PAYMENT or SUBSCRIBER_REFUND",
  "statusCode": 422
}
```

---

# lumail.tokens
Source: https://lumail.io/docs/sdk/v2/tokens.md

`lumail@2.1.0` talks to `/api/v2/tokens`.

Organization `lum_` tokens. Permission: `tokens`.

---

# lumail.tokens.list
Source: https://lumail.io/docs/sdk/v2/tokens-list.md

`lumail@2.1.0` · [`lumail.tokens`](/docs/sdk/v2/tokens)

```typescript
tokens.list(): Promise<LumailResult<ListApiTokensResponse>>
```

Secrets are last-4 only. The SDK takes no arguments and does not forward `?limit=`. The HTTP route may still accept `limit` if you call it with fetch.

## Parameters

No parameters.

## Response Fields

| Field | Type | Description |
| ---------- | ------------------- | ---------------------------------------------------------------------------------------------------------- |
| `object` | `"list"` | Discriminator. Always `list`. |
| `has_more` | boolean | `true` when the HTTP `limit` slice hid more rows. The SDK never sends `limit`, so this is usually `false`. |
| `data` | `ApiTokenSummary[]` | Tokens. Each row: `id`, `name`, `last4`, `permissions`, `createdAt`. |

`data` is `{ object, data, has_more }`. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** GET
**Endpoint:** /api/v2/tokens

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.tokens.list();
if (error) {
  throw error;
}
for (const token of data.data) {
  console.log(token.name, token.last4);
}
```

### cURL
```bash
curl https://lumail.io/api/v2/tokens \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/tokens", {
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
  },
});
const data = await response.json();
```

### Success Response
```json
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "tok_abc123",
      "name": "CI",
      "last4": "9k2m",
      "permissions": ["emails", "subscribers"],
      "createdAt": "2026-09-05T10:30:00.000Z"
    }
  ]
}
```

### Error Response
```json
{
  "name": "missing_api_key",
  "message": "Missing API key",
  "statusCode": 401
}
```

---

# lumail.tokens.create
Source: https://lumail.io/docs/sdk/v2/tokens-create.md

`lumail@2.1.0` · [`lumail.tokens`](/docs/sdk/v2/tokens)

```typescript
tokens.create(params: CreateApiTokenParams): Promise<LumailResult<CreateApiTokenResponse>>
```

Secret returned once. Optional `preset` or `permissions`.

## Body Parameters

| Name | Type | Description |
| --------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------ |
| `name` | string | **Required.** Token label. |
| `reuseIfExists` | boolean | When `true`, return an existing token with the same name instead of minting another. |
| `permissions` | string[] | Explicit permission list. |
| `preset` | `"app"` / `"marketing"` / `"sender"` / `"full"` | Permission preset. Used when `permissions` is omitted. |

## Response Fields

| Field | Type | Description |
| ------------- | -------- | ------------------------------------------------------- |
| `id` | string | Token id. |
| `name` | string | Token label. |
| `token` | string | Full `lum_` secret. Shown once. |
| `permissions` | string[] | Granted permissions. |
| `createdAt` | string | ISO 8601 timestamp. |
| `reused` | boolean | `true` when `reuseIfExists` returned an existing token. |

`data` is the minted token. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** POST
**Endpoint:** /api/v2/tokens

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.tokens.create({
  name: "CI",
  preset: "app",
});
if (error) {
  throw error;
}
console.log(data.token);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/tokens \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"CI","preset":"app"}'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/tokens", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ name: "CI", preset: "app" }),
});
const data = await response.json();
```

### Success Response
```json
{
  "id": "tok_abc123",
  "name": "CI",
  "token": "lum_...",
  "permissions": ["emails", "subscribers"],
  "createdAt": "2026-09-05T10:30:00.000Z",
  "reused": false
}
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "name is required",
  "statusCode": 422
}
```

---

# lumail.tools.list
Source: https://lumail.io/docs/sdk/v2/tools-list.md

`lumail@2.1.0` · [`lumail.tools`](/docs/sdk/v2/tools)

```typescript
tools.list(): Promise<LumailResult<ListToolsResponse>>
```

Catalog. The SDK takes no arguments and does not forward `?limit=`. The HTTP route may still accept `limit` if you call it with fetch.

## Parameters

No parameters.

## Response Fields

| Field | Type | Description |
| ---------- | ------------------ | ---------------------------------------------------------------------------------------------------------- |
| `object` | `"list"` | Discriminator. Always `list`. |
| `has_more` | boolean | `true` when the HTTP `limit` slice hid more rows. The SDK never sends `limit`, so this is usually `false`. |
| `data` | `ToolDefinition[]` | Tools. Each row: `name`, `description`, optional `category`, `inputSchema`, `outputSchema`. |

`data` is `{ object, data, has_more }`. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** GET
**Endpoint:** /api/v2/tools

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.tools.list();
if (error) {
  throw error;
}
for (const tool of data.data) {
  console.log(tool.name);
}
```

### cURL
```bash
curl https://lumail.io/api/v2/tools \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/tools", {
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
  },
});
const data = await response.json();
```

### Success Response
```json
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "name": "verify_email",
      "description": "Check syntax and deliverability signals for one address."
    }
  ]
}
```

### Error Response
```json
{
  "name": "missing_api_key",
  "message": "Missing API key",
  "statusCode": 401
}
```

---

# lumail.tools.get
Source: https://lumail.io/docs/sdk/v2/tools-get.md

`lumail@2.1.0` · [`lumail.tools`](/docs/sdk/v2/tools)

```typescript
tools.get(toolName: string): Promise<LumailResult<GetToolResponse>>
```

One tool and its input schema.

## Path Parameters

| Name | Type | Description |
| ---------- | ------ | ----------------------------------------- |
| `toolName` | string | **Required.** Tool name (`^[a-z0-9_]+$`). |

## Response Fields

| Field | Type | Description |
| -------------- | ------ | ------------------------------------ |
| `name` | string | Tool name. |
| `description` | string | What the tool does. |
| `category` | string | Optional grouping. |
| `inputSchema` | object | JSON Schema for the run body. |
| `outputSchema` | object | Optional JSON Schema for the result. |

HTTP also includes `endpoint` (`/api/v2/tools/{name}`). `data` is the tool definition. Failures return `{ data: null, error: { name, message, statusCode } }`.


## API Reference
**Method:** GET
**Endpoint:** /api/v2/tools/:toolName

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.tools.get("list_subscribers");
if (error) {
  throw error;
}
console.log(data.name, data.inputSchema);
```

### cURL
```bash
curl https://lumail.io/api/v2/tools/list_subscribers \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/tools/list_subscribers", {
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
  },
});
const data = await response.json();
```

### Success Response
```json
{
  "name": "list_subscribers",
  "description": "List contacts newest first.",
  "inputSchema": { "type": "object" }
}
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Tool \"list_subscribers\" not found",
  "statusCode": 404
}
```

---

# lumail.tools.run
Source: https://lumail.io/docs/sdk/v2/tools-run.md

`lumail@2.1.0` · [`lumail.tools`](/docs/sdk/v2/tools)

```typescript
tools.run<T>(toolName: string, params?: Record<string, unknown>, options?: { autoConfirm?: boolean }): Promise<LumailResult<RunToolResponse<T>>>
```

`autoConfirm: true` retries once with `confirmationCode` from `error.details` when the first response is `confirmation_required`. Dangerous tools (send, delete, unsubscribe) challenge unless you pass that code.

## Path Parameters

| Name | Type | Description |
| ---------- | ------ | ----------------------------------------- |
| `toolName` | string | **Required.** Tool name (`^[a-z0-9_]+$`). |

## Body Parameters

| Name | Type | Description |
| -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `params` | `Record` | Second argument. Tool input, sent as the JSON body. Defaults to `{}`. Shape is the tool's `inputSchema`. Dangerous retries may include `confirmationCode`. |

## Options

| Name | Type | Description |
| ------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `autoConfirm` | boolean | Third-argument option. When `true` and the first error is `confirmation_required`, the SDK POSTs again with `confirmationCode` from `error.details`. |

## Response Fields

| Field | Type | Description |
| ----------------- | ---- | ----------------------------------------------------------------------------------------------------- |
| *(tool-specific)* | `T` | HTTP JSON body of the tool. Example: `list_subscribers` returns `{ object: "list", data, has_more }`. |

When confirmation is required, `error` is `{ name: "confirmation_required", message, statusCode: 403, details: { action, confirmationCode, instruction } }`.


## API Reference
**Method:** POST
**Endpoint:** /api/v2/tools/:toolName

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.tools.run(
  "list_subscribers",
  { limit: 10 },
  { autoConfirm: true },
);
if (error) {
  throw error;
}
console.log(data);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/tools/list_subscribers \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"limit":10}'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/tools/list_subscribers", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ limit: 10 }),
});
const data = await response.json();
```

### Success Response
```json
{
  "object": "list",
  "has_more": false,
  "data": []
}
```

### Error Response
```json
{
  "name": "confirmation_required",
  "message": "This action requires confirmation",
  "statusCode": 403,
  "details": {
    "action": "send_email",
    "confirmationCode": 482910,
    "instruction": "Retry with confirmationCode"
  }
}
```

---

# Errors
Source: https://lumail.io/docs/sdk/v2/errors.md

`lumail@2.1.0` returns `{ data, error }` for HTTP calls. Construction still throws if `apiKey` is missing.

API errors keep the HTTP `name`, `message`, and `statusCode`. Permission and confirmation extras live on `error.details`. A 403 is never remapped to 400.

Transport failures use `error.name` of `network_error`, `timeout`, or `aborted` and omit `statusCode`.

GET/PUT/DELETE retry on 429 and retryable network failures. POST/PATCH do not.

`lumail.tools.run(name, params, { autoConfirm: true })` retries once with `confirmationCode` when the tool requires confirm. Default is no auto-confirm.

---

# lumail.emails.verify
Source: https://lumail.io/docs/sdk/v2/emails-verify.md

Prefer [`lumail.verify()`](/docs/sdk/v2/verify).

```typescript
emails.verify({ email: string }): Promise<LumailResult<VerifyEmailResponse>>
```

Still returns `{ valid: true }` or `{ valid: false, error, code }` from `/api/v2/emails/verify`. A timeout is `{ data: null, error: { name: "timeout" } }`. `{ valid: false, error, code }` is still HTTP 200 and lands in `data`.

## Body Parameters

| Name | Type | Description |
| ------- | ------ | ------------------------------- |
| `email` | string | **Required.** Address to check. |

## Response Fields

| Field | Type | Description |
| ------------ | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| `valid` | boolean | `true` when the address passes. `false` on a completed check that failed. |
| `warnings` | string[] | Optional warnings on either outcome. |
| `error` | string | Present when `valid` is `false`. Human-readable reason. |
| `code` | `"invalid_format"` / `"disposable_email"` / `"spam_domain"` / `"invalid_domain"` / `"test_email"` / `"internal_error"` | Present when `valid` is `false`. |
| `suggestion` | string | Optional correction when `valid` is `false`. |


## API Reference
**Method:** POST
**Endpoint:** /api/v2/emails/verify

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.emails.verify({
  email: "user@example.com",
});
if (error) {
  throw error;
}
console.log(data.valid);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/emails/verify \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com"}'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/emails/verify", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ email: "user@example.com" }),
});
const data = await response.json();
```

### Success Response
```json
{ "valid": true }
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "email is required",
  "statusCode": 422
}
```

---

# API Tokens
Source: https://lumail.io/docs/api-reference/api-tokens.md

API tokens authenticate requests without user credentials. Each `lum_` token is scoped to one organization and to a **permission set**. OAuth CLI sessions stay full-access operators. Existing tokens created before permissions shipped keep full access (`*`).

## Creating an API Token

### From the CLI (recommended for agents)

After `lumail auth login`, mint a durable token for an app or CI:

```bash
lumail tokens create --name "In-app integration"
```

Defaults to the **App** preset: `subscribers`, `emails`, `audience`. That is
enough for SDK upserts and SMTP. It cannot send campaigns or mint tokens.

```bash
lumail tokens create --name "In-app integration" --full
lumail tokens create --name sender --preset sender
lumail tokens create --name custom --permissions subscribers,audience
```

Reuse only works when the same name **and** the same permission set already
exist. A full token named `In-app integration` will not be returned for an App
create — that is `409` `token_permission_mismatch`. Use `--no-reuse` or a
different name. The `lum_` secret is printed once. `lumail tokens list` shows
names, last-4, and permissions.

### Step 1: Navigate to API Tokens Page

1. Log in to your Lumail account
2. Select your organization from the sidebar
3. Go to **Settings** → **API Tokens**
4. Click the **Generate Token** button

### Step 2: Name Your Token and pick permissions

Give your token a descriptive name and a permission preset. The dashboard
defaults to **App**. Full access is an explicit choice. Permissions cannot be
edited later — rotate the token instead.

Presets:

- **App** — subscribers, emails, audience
- **Marketing editor** — App plus campaigns, analytics, workflows
- **Sender** — marketing editor plus campaign send/schedule
- **Full** — everything, including tokens and domains
- **Custom** — any set of groups

A missing group returns **403** with `code: "missing_permission"` and
`required` set to the group (not a 404).

Give your token a descriptive name to help you identify its purpose later:

- `Production API` - For production applications
- `Development` - For local development
- `Integration Testing` - For CI/CD pipelines
- `Mobile App` - For mobile applications

### Step 3: Save Your Token

**IMPORTANT:** After creation, your token will be displayed only once. Copy and store it securely immediately.

```
lum_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6
```

The token format is: `lum_` followed by a 64-character random string.

## Using Your API Token

### Authentication Header

All API requests must include the token in the `Authorization` header using the Bearer authentication scheme:

```
Authorization: Bearer lum_your_token_here
```

### Basic Request Structure

Every API request should include:

1. **Authorization Header** - Your API token with Bearer prefix
2. **Content-Type Header** - Usually `application/json`
3. **Request Body** - For POST/PUT/PATCH requests (JSON format)

## Usage Examples

### Example 1: Sending a Transactional Email

```javascript
const response = await fetch("https://lumail.io/api/v1/emails", {
  method: "POST",
  headers: {
    Authorization: "Bearer lum_your_token_here",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    to: "user@example.com",
    subject: "Welcome!",
    content: "Hello {{name}}, welcome to our platform!",
    from: "hello@yourdomain.com",
  }),
});

const data = await response.json();
console.log(data);
```

> **Test-mode recipients.** Fixture addresses such as `user@example.com` never send real email. See [Test-mode recipients](/docs/api-reference/test-mode).

### Example 2: Creating a Subscriber

```javascript
const response = await fetch("https://lumail.io/api/v1/subscribers", {
  method: "POST",
  headers: {
    Authorization: "Bearer lum_your_token_here",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    email: "newuser@example.com",
    name: "Jane Smith",
    tags: ["newsletter", "promotional"],
  }),
});

const data = await response.json();
console.log(data);
```

### Example 3: Listing All Tags

```javascript
const response = await fetch("https://lumail.io/api/v1/tags", {
  method: "GET",
  headers: {
    Authorization: "Bearer lum_your_token_here",
    "Content-Type": "application/json",
  },
});

const data = await response.json();
console.log(data.tags);
```

### Example 4: Error Handling

Always implement proper error handling for API requests:

```javascript
try {
  const response = await fetch("https://lumail.io/api/v1/subscribers/sub_123", {
    method: "GET",
    headers: {
      Authorization: "Bearer lum_your_token_here",
      "Content-Type": "application/json",
    },
  });

  if (!response.ok) {
    const error = await response.json();
    console.error("API Error:", error.message);
    return;
  }

  const data = await response.json();
  console.log(data);
} catch (error) {
  console.error("Network Error:", error);
}
```

## Best Practices

### Security

1. **Never commit tokens to version control** - Use environment variables
2. **Store tokens securely** - Use secret management services
3. **Rotate tokens regularly** - Generate new tokens periodically
4. **Use separate tokens** - Different tokens for dev, staging, and production
5. **Delete unused tokens** - Remove tokens you're no longer using

### Environment Variables

Store your API token in environment variables:

```bash
# .env.local (DO NOT commit this file)
LUMAIL_API_TOKEN=lum_your_token_here
```

Then use it in your code:

```javascript
const response = await fetch("https://lumail.io/api/v1/subscribers", {
  headers: {
    Authorization: `Bearer ${process.env.LUMAIL_API_TOKEN}`,
    "Content-Type": "application/json",
  },
});
```

### Rate Limiting

API requests are rate-limited based on your subscription plan. See [Rate Limits](/docs/api-reference/api-limits) for full details. Implement exponential backoff for failed requests:

```javascript
async function fetchWithRetry(url, options, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    const response = await fetch(url, options);

    if (response.ok) {
      return response;
    }

    if (response.status === 429) {
      // Rate limited - wait and retry
      const waitTime = Math.pow(2, i) * 1000;
      await new Promise((resolve) => setTimeout(resolve, waitTime));
      continue;
    }

    throw new Error(`HTTP ${response.status}: ${await response.text()}`);
  }

  throw new Error("Max retries exceeded");
}
```

## Common Errors

### 401 Unauthorized - Missing Authorization Header

```json
{
  "message": "Missing or invalid Authorization header"
}
```

**Solution:** Ensure you're including the `Authorization: Bearer <token>` header in your request.

### 401 Unauthorized - Invalid API Token

```json
{
  "message": "Invalid API token"
}
```

**Solution:**

- Verify your token is correct (no extra spaces or characters)
- Check if the token has been deleted from the settings page
- Generate a new token if necessary

### 404 Not Found - Resource Not Found

```json
{
  "error": "Subscriber not found"
}
```

**Solution:** Verify the resource ID exists in your organization.

### 403 Forbidden - Missing Permission

```json
{
  "message": "Missing permission: send",
  "code": "missing_permission",
  "required": "send"
}
```

**Solution:** Mint a token that includes the required group (`--preset sender` / `--full`, or a custom `--permissions` list). Permissions cannot be edited — rotate the token.

The SDK throws `LumailPermissionError` with `required`. The CLI prints the group and a mint hint.

### 400 Bad Request - Validation Error

```json
{
  "message": "Validation failed",
  "errors": [
    {
      "field": "email",
      "message": "Invalid email format"
    }
  ]
}
```

**Solution:** Check your request body matches the expected schema for the endpoint.

## Managing Tokens

### Viewing Active Tokens

Go to **Settings** → **API Tokens** to see all active tokens for your organization:

- Token name
- Creation date
- Last used date (if applicable)
- Last 7 days of actions (REST, v2 tools, MCP) with success / warning / error messages. Rows older than 7 days are deleted automatically. The raw `lum_` secret is never stored on a log row.

### Deleting Tokens

To delete a token:

1. Navigate to **Settings** → **API Tokens**
2. Find the token you want to delete
3. Click the delete icon
4. Confirm the deletion

**Note:** Deleting a token immediately revokes access. Any applications using that token will fail authentication.

### Token Rotation

For security best practices, rotate your tokens periodically:

1. Generate a new token
2. Update your applications to use the new token
3. Test that the new token works
4. Delete the old token

## Organization Scope

Each API token is scoped to a specific organization. The token provides access to:

- All subscribers in your organization
- All campaigns and emails
- All tags and custom fields
- All analytics and reports
- Organization settings (where applicable)

Tokens **cannot** access:

- Other organizations' data
- User account settings
- Billing information beyond basic plan details

## Related Documentation

Now that you have your API token set up, explore the available API endpoints:

- [Send Transactional Emails](/docs/api-reference/api-emails-send) - Send emails via API
- [Create Subscribers](/docs/api-reference/api-subscribers-post) - Add new subscribers
- [Manage Subscribers](/docs/api-reference/api-subscribers-get) - Retrieve subscriber data
- [Work with Tags](/docs/api-reference/api-tags-get) - Manage subscriber tags
- [Track Events](/docs/api-reference/api-events-post) - Record subscriber activity

### Tutorials

- [Create API Token Tutorial](/docs/tutorials/create-api-token) - Step-by-step token setup
- [Dynamic Promo Codes](/docs/tutorials/dynamic-promo-codes) - Use tokens in webhooks

---

# TypeScript SDK
Source: https://lumail.io/docs/api-reference/sdk.md

The Lumail TypeScript SDK provides a type-safe client for interacting with the Lumail API. It covers all V1 REST endpoints and V2 tools, with built-in error handling, retries, and full TypeScript autocompletion.

For a task-focused guide with separate pages for setup, resources, tools, and error handling, start with the [SDK Introduction](/docs/sdk/introduction).

## Installation

```bash
npm install lumail
```

## Quick Start

```typescript
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: "lum_your_api_token_here" });

// Create a subscriber
const { subscriber } = await lumail.subscribers.create({
  email: "user@example.com",
  name: "John Doe",
  tags: ["newsletter"],
});

// Send a campaign
await lumail.campaigns.send("campaign_id");
```

## Configuration

```typescript
const lumail = new Lumail({
  apiKey: "lum_...", // Required - your API token
  baseUrl: "https://lumail.io/api", // Optional - defaults to production
});
```

Get your API token from **Settings > API Tokens** in your Lumail dashboard, or follow the [API Tokens guide](/docs/api-reference/api-tokens).

## Subscribers

### Create or update a subscriber

```typescript
const { subscriber } = await lumail.subscribers.create({
  email: "user@example.com",
  name: "John Doe",
  phone: "+1234567890",
  tags: ["vip", "newsletter"],
  fields: { company: "Acme", role: "CEO" },
  resubscribe: true, // Re-subscribe if previously unsubscribed
  triggerWorkflows: true, // Trigger matching workflows
  skipDoubleOptIn: true, // Trusted backends only; skip org double opt-in
  country: "US", // 2-letter country code
});
```

If the email already exists, it updates the existing subscriber.

### Get a subscriber

```typescript
const { subscriber } = await lumail.subscribers.get("user@example.com");
// or by ID
const { subscriber } = await lumail.subscribers.get("sub_abc123");
```

### Update a subscriber

```typescript
const { subscriber } = await lumail.subscribers.update("user@example.com", {
  name: "Jane Doe",
  tags: ["premium"],
  replaceTags: true, // Replace all tags instead of appending
});
```

### Unsubscribe

```typescript
const { subscriber } = await lumail.subscribers.unsubscribe("user@example.com");
```

### Manage tags

```typescript
// Add tags (creates tags if they don't exist)
const { added, tags } = await lumail.subscribers.addTags("user@example.com", [
  "premium",
  "beta-tester",
]);

// Remove tags
const { removed } = await lumail.subscribers.removeTags("user@example.com", [
  "old-tag",
]);
```

### List events

```typescript
const { events, nextCursor } = await lumail.subscribers.listEvents(
  "user@example.com",
  {
    take: 50,
    order: "desc",
    eventTypes: ["EMAIL_OPENED", "EMAIL_CLICKED"],
    startDate: "2025-01-01T00:00:00Z",
  },
);

// Cursor-based pagination
if (nextCursor) {
  const next = await lumail.subscribers.listEvents("user@example.com", {
    cursor: nextCursor,
    take: 50,
  });
}
```

## Campaigns

### List campaigns

```typescript
const { campaigns, total, pageCount } = await lumail.campaigns.list({
  status: "DRAFT", // "all" | "DRAFT" | "ARCHIVED" | "SCHEDULED" | "SENT"
  page: 1,
  limit: 20,
  query: "welcome", // Search by name or subject
  sortBy: "name", // "name" | "name_desc"
});
```

### Create a campaign

```typescript
const { campaign, campaignId } = await lumail.campaigns.create({
  subject: "Welcome to our newsletter!",
  name: "Welcome Campaign",
  preview: "You're in. Here's what to expect.",
  contentType: "MARKDOWN", // "MAILY" | "PLATE" | "MARKDOWN"
});
```

### Get campaign details

```typescript
const { campaign } = await lumail.campaigns.get("campaign_id");
// Includes sender info, recipient filters, and full content
```

### Update a campaign

Only DRAFT campaigns can be updated.

```typescript
await lumail.campaigns.update("campaign_id", {
  subject: "Updated Subject Line",
  preview: "New preview text",
});
```

### Delete a campaign

Only DRAFT campaigns can be deleted.

```typescript
await lumail.campaigns.delete("campaign_id");
```

### Send or schedule

```typescript
// Send immediately
await lumail.campaigns.send("campaign_id");

// Schedule for later
await lumail.campaigns.send("campaign_id", {
  scheduledAt: "2025-12-25T10:00:00Z",
  timezone: "Europe/Paris",
});
```

## Emails (Transactional)

### Send an email

```typescript
const { id } = await lumail.emails.send({
  to: "user@example.com",
  from: "noreply@yourdomain.com",
  subject: "Order Confirmation",
  markdown: "Your order **#1234** has been confirmed.",
  reply_to: "support@yourdomain.com",
});
```

> **Test-mode recipients.** Fixture addresses such as `user@example.com` never send real email. See [Test-mode recipients](/docs/api-reference/test-mode).

The `from` address must belong to a verified domain in your organization.

### Verify an email

```typescript
const { data, error } = await lumail.verify("test@example.com");

if (error) throw error;
if (!data.accept) {
  console.log(data.issues[0]?.code, data.issues[0]?.message);
}
```

`lumail.verify()` hits `POST /api/v2/verify`. See [Verify](/docs/api-reference/v2/verify). *Legacy `lumail.emails.verify()` still returns the old shape.*

## Tags

```typescript
// List all tags
const { tags } = await lumail.tags.list();

// Create a tag
const { tag } = await lumail.tags.create({ name: "premium" });

// Get tag details (by name or ID)
const { tag } = await lumail.tags.get("premium");
// tag.subscribersCount shows how many subscribers have this tag

// Rename a tag
await lumail.tags.update("premium", { name: "gold" });
```

## Events

Track custom subscriber events:

```typescript
await lumail.events.create({
  eventType: "SUBSCRIBER_PAYMENT",
  subscriber: "user@example.com", // Email or subscriber ID
  data: {
    amount: 99,
    plan: "pro",
    currency: "USD",
  },
});
```

Available event types: `SUBSCRIBED`, `UNSUBSCRIBED`, `TAG_ADDED`, `TAG_REMOVED`, `EMAIL_OPENED`, `EMAIL_CLICKED`, `EMAIL_SENT`, `EMAIL_RECEIVED`, `WORKFLOW_STARTED`, `WORKFLOW_COMPLETED`, `WORKFLOW_CANCELED`, `FIELD_UPDATED`, `EMAIL_BOUNCED`, `EMAIL_COMPLAINED`, `WEBHOOK_EXECUTED`, `SUBSCRIBER_PAYMENT`, `SUBSCRIBER_REFUND`

## Tools (V2 API)

Access all 59+ Lumail tools programmatically:

```typescript
// List available tools
const { tools, grouped } = await lumail.tools.list();

// Get a tool's schema
const { tool } = await lumail.tools.get("list_subscribers");

// Run a tool with typed response
const result = await lumail.tools.run<{ subscribers: unknown[] }>(
  "list_subscribers",
  { limit: 10, status: "SUBSCRIBED" },
);
```

See [Tools API (v2)](/docs/api-reference/v2-tools) for the full list of available tools.

## Error Handling

The SDK throws typed errors for different HTTP status codes:

```typescript
import {
  Lumail,
  LumailAuthenticationError, // 401 - invalid API key
  LumailPaymentRequiredError, // 402 - plan limit reached
  LumailValidationError, // 400 - invalid request
  LumailNotFoundError, // 404 - resource not found
  LumailRateLimitError, // 429 - rate limited
  LumailError, // Other errors
} from "lumail";

try {
  await lumail.subscribers.get("unknown@example.com");
} catch (error) {
  if (error instanceof LumailNotFoundError) {
    console.log("Subscriber not found");
  } else if (error instanceof LumailRateLimitError) {
    console.log(`Rate limited. Retry after ${error.retryAfter}ms`);
  } else if (error instanceof LumailAuthenticationError) {
    console.log("Check your API key");
  }
}
```

## Retry Behavior

| Method           | Retries    | When                            |
| ---------------- | ---------- | ------------------------------- |
| GET, PUT, DELETE | Up to 3    | Network errors, 429 rate limits |
| POST, PATCH      | No retries | Prevents duplicate operations   |

Retry delays follow exponential backoff: 1s, 2s, 4s. The SDK respects `Retry-After` headers from rate limit responses.

## Related Documentation

- [API Tokens](/docs/api-reference/api-tokens) - Generate your API key
- [SDK Introduction](/docs/sdk/introduction) - Task-focused SDK guide
- [CLI](/docs/api-reference/cli) - Command-line interface
- [MCP Server](/docs/api-reference/mcp) - AI agent integration
- [API Limits](/docs/api-reference/api-limits) - Rate limits and quotas

---

# CLI
Source: https://lumail.io/docs/api-reference/cli.md

The Lumail CLI lets you manage your email marketing directly from the terminal. It uses the same SDK as the TypeScript library, so every operation available in the SDK is also available via CLI.

## Installation

```bash
npm install -g lumail
```

Or run without installing:

```bash
npx lumail <command>
```

## Create an account with an AI agent

Coding agents cannot use interactive prompts. Start a session and follow the
printed next command every time:

```bash
npx lumail start --agents
# Now you need to give us the email.
#   npx lumail start --session start_xxx --email you@domain.com

npx lumail start --session start_xxx --email ada@example.com
# Now you need to give us the 6-digit sign-in code.
#   npx lumail start --session start_xxx --otp 123456

npx lumail start --session start_xxx --otp 123456
# Now you need to give us the website or the organization name.
#   npx lumail start --session start_xxx --website lumail.io
#   npx lumail start --session start_xxx --org "Ada Labs"

npx lumail start --session start_xxx --website lumail.io --org "Ada Labs"
# Now you need to give us the sending domain.
#   npx lumail start --session start_xxx lumail.io

npx lumail start --session start_xxx lumail.io
# Now add the DNS records, then verify.
#   npx lumail start --session start_xxx --verify-domain

npx lumail start --session start_xxx --verify-domain
# Now you need to give us the default sender.
#   npx lumail start --session start_xxx --sender hello

npx lumail start --session start_xxx --sender hello
# Now install Lumail for your detected AI agents.
#   npx lumail start --session start_xxx --install-agents
```

`--agents` prints a session id. Each step exits with the exact next command.
Skip a later step with `--skip-domain`, `--skip-verify`, or `--no-agents`.
`--json` returns the same `session`, `step`, `hint`, and `next` fields.

A real terminal can still run the interactive one-shot:

```bash
npx lumail start
```

Useful options:

| Option               | Effect                                               |
| -------------------- | ---------------------------------------------------- |
| `--session <id>`     | Continue this agent onboarding session               |
| `--agents`           | Start a session and print the first next command     |
| `--email <email>`    | Account email                                        |
| `--otp <code>`       | 6-digit email code                                   |
| `--org <name>`       | Organization name                                    |
| `--website <domain>` | Brand website, same as `/orgs/new`                   |
| `--domain <domain>`  | Sending domain; a positional domain also works       |
| `--sender <local>`   | Default from-address local part, e.g. `hello`        |
| `--no-sender-domain` | Finish account and agent setup without a domain      |
| `--no-agents`        | Skip skill and MCP installation                      |
| `--no-agent-auth`    | Configure MCP clients without opening their OAuth UI |
| `--account <name>`   | Save and reuse this as a named Lumail account        |

Re-running `start` is safe. A valid login is reused, existing domains are read
back instead of recreated, and agent JSON configuration is merged without
removing unrelated servers.

`start` is an onboarding command, not an MCP server. Never register
`npx lumail start` itself as an MCP command; it configures the remote
`https://lumail.io/mcp` connection.

## Authentication

Browser OAuth is recommended because tokens refresh automatically:

```bash
lumail auth login
```

You can still save an API key manually:

```bash
lumail auth set lum_your_api_token_here
```

Get your API token from **Settings > API Tokens** in your Lumail dashboard, or follow the [API Tokens guide](/docs/api-reference/api-tokens).

```bash
# Show saved token (masked)
lumail auth show

# Show full token
lumail auth show --raw

# Verify token works
lumail auth test

# Remove saved token
lumail auth remove
```

The token is stored at `~/.config/lumail/token` with restricted file permissions.

After a browser sign-in, mint a durable `lum_` token for an app:

```bash
lumail auth login
lumail tokens create --name "In-app integration"
```

`tokens create` defaults to App permissions (`subscribers`, `emails`,
`audience`). Pass `--full`, `--preset marketing|sender|full`, or
`--permissions subscribers,audience`. Reuse requires the same name and the
same permission set.

## Sender domains

Create or resume a sender domain and print its DNS records plus an agent-ready
setup prompt:

```bash
lumail domain setup example.com
```

After DNS propagation, verify by domain name or the returned domain ID:

```bash
lumail domain verify example.com
lumail domain verify domain_123
```

The verification command reads back the authoritative Lumail/SES state and
prints every remaining DNS record with its current status.

## AI-agent integration

To install or repair only the skill and MCP connections, without repeating
account or domain onboarding:

```bash
lumail setup
lumail setup --authenticate
```

`--authenticate` opens OAuth for clients with a supported CLI. Windsurf requests
OAuth the first time the Lumail MCP connection is used.

## Named Accounts

Use named accounts when you manage multiple organizations from the same machine:

```bash
lumail accounts add lumail lum_your_api_token_here
lumail accounts add client-a lum_client_api_token_here
```

Then run any command with `-a` or `--account`:

```bash
lumail subscribers list -a lumail
lumail campaigns list --account client-a
```

Named account tokens are stored at `~/.config/lumail/accounts.json` with restricted file permissions. Commands without `-a` keep using the legacy token from `lumail auth set`, so no migration is required.

## Admin API keys

Platform admins mint a `luma_` key from **Admin → API Keys**. Save it like any other token, then list organizations and run the main CLI against one of them:

```bash
lumail auth set luma_your_admin_key_here
lumail auth test

lumail admin orgs list --search acme --plan pro --status verified
lumail admin orgs get acme

lumail subscribers list --organization acme
lumail campaigns create --organization acme --subject "Welcome" --name "Welcome"
lumail tools run add_subscriber --organization acme --params '{"email":"user@example.com"}'
```

`--organization` accepts an organization id or slug. It is required for every org-scoped command when the saved token is an admin key. Generate the key in the admin UI; the plaintext is shown once.

```bash
lumail admin tools list
lumail admin tools run list_orgs --params '{"search":"acme","pageSize":20}'
```

```bash
# List saved named accounts
lumail accounts list

# Show a named account token (masked)
lumail accounts show lumail

# Show the full token
lumail accounts show lumail --raw

# Remove a named account
lumail accounts remove lumail
```

## Global Flags

Every command supports these flags:

| Flag                         | Description                            |
| ---------------------------- | -------------------------------------- |
| `-a, --account <name>`       | Use a named API key account            |
| `--organization <id-or-slug>` | Target an organization (admin API keys) |
| `--json`                     | Output as JSON                         |
| `--format <text\|json\|csv>` | Output format (default: text)          |
| `--verbose`                  | Enable debug logging                   |
| `--no-color`                 | Disable colored output                 |
| `--no-header`                | Omit table headers (useful for piping) |

```bash
# Get JSON output
lumail campaigns list --json

# CSV for spreadsheets
lumail subscribers events user@example.com --format csv

# Pipe to jq
lumail campaigns list --json | jq '.data.campaigns[].subject'
```

## Subscribers

### Create or update

```bash
lumail subscribers create --email user@example.com --name "John Doe" --tags vip newsletter
lumail subscribers create --email buyer@example.com --tags course-bought --skip-double-opt-in
```

### Get details

```bash
lumail subscribers get user@example.com
lumail subscribers get sub_abc123
```

### Update

```bash
lumail subscribers update user@example.com --name "Jane Doe"
lumail subscribers update user@example.com --tags premium --replace-tags
```

### Unsubscribe

```bash
lumail subscribers unsubscribe user@example.com
```

### Manage tags

```bash
# Add tags
lumail subscribers add-tags user@example.com --tags vip premium

# Remove tags
lumail subscribers remove-tags user@example.com --tags old-tag
```

### List events

```bash
lumail subscribers events user@example.com
lumail subscribers events user@example.com --take 50 --order asc
```

## Campaigns

### List campaigns

```bash
lumail campaigns list
lumail campaigns list --status DRAFT
lumail campaigns list --query "welcome" --limit 50
```

### Create

```bash
lumail campaigns create --subject "Welcome!" --name "Welcome Campaign"
```

### Get details

```bash
lumail campaigns get <campaign_id>
```

### Update (DRAFT only)

```bash
lumail campaigns update <campaign_id> --subject "Updated Subject" --preview "New preview"
```

### Delete (DRAFT only)

```bash
lumail campaigns delete <campaign_id>
```

### Send or schedule

```bash
# Send immediately
lumail campaigns send <campaign_id>

# Schedule for later
lumail campaigns send <campaign_id> --scheduled-at 2025-12-25T10:00:00Z --timezone UTC
```

## Tokens

Mint organization API tokens after `lumail auth login`. These are the durable
`lum_` secrets you put in `LUMAIL_API_KEY` — the OAuth session itself is not
an app token.

```bash
# App preset (subscribers, emails, audience)
lumail tokens create --name "In-app integration"

# Full access
lumail tokens create --name "ops" --full

# Always mint a new secret
lumail tokens create --name "CI" --no-reuse

# List tokens (last-4 and permissions)
lumail tokens list
```

## Tags

```bash
# List all tags
lumail tags list

# Create a tag
lumail tags create --name "premium"

# Get tag details
lumail tags get premium

# Rename a tag
lumail tags update <tag_id> --name "gold"
```

## Emails (Transactional)

### Send an email

```bash
lumail emails send \
  --to user@example.com \
  --from noreply@yourdomain.com \
  --subject "Order Confirmation" \
  --content "Your order #1234 has been confirmed." \
  --content-type MARKDOWN
```

> **Test-mode recipients.** Fixture addresses such as `user@example.com` never send real email. See [Test-mode recipients](/docs/api-reference/test-mode).

Prints `{ message, id }` (`id` is `eml_<dispatchId>`, same as `GET /v1/emails/:id`). JSON mode wraps that in `{ ok: true, data }`.

Options: `--reply-to`, `--transactional`, `--content-type` (MARKDOWN, HTML, TIPTAP)

### Verify an email

```bash
lumail emails verify user@example.com
```

## Events

```bash
lumail events create \
  --type SUBSCRIBER_PAYMENT \
  --subscriber user@example.com \
  --data '{"amount": 99, "plan": "pro"}'
```

## Tools (V2 API)

Access every tool available to the current organization from the terminal. Workflow tools are included for every organization:

```bash
# List all tools
lumail tools list

# Get tool schema
lumail tools get list_subscribers

# Run a tool
lumail tools run list_subscribers --params '{"limit": 10}'
lumail tools run create_campaign --params '{"name": "Test", "subject": "Hello"}'

# Inspect the Workflow setup contract before building a complete draft
lumail tools get configure_workflow_draft

# Discover and inspect Workflow groups before assignment
lumail tools run list_workflow_groups --params '{}'
lumail tools get set_workflow_group
```

### Custom subscriber fields

Create field definitions without opening the dashboard. This does not set subscriber values:

```bash
lumail tools get create_custom_field --account my-org --json

lumail tools run create_custom_field \
  --account my-org \
  --json \
  --params '{"name":"has_double_opt_in"}'

lumail tools run create_custom_field \
  --account my-org \
  --json \
  --params '{"name":"has_custom_domains"}'

lumail tools run get_custom_fields --account my-org --json
```

### Workflow group example

```bash
lumail tools run create_workflow_group \
  --account my-org \
  --params '{"name":"Onboarding","conflictStrategy":"SKIP_NEW"}'

lumail tools run set_workflow_group \
  --account my-org \
  --params '{"workflowId":"workflow-id","expectedUpdatedAt":"2026-07-27T10:00:00.000Z","groupId":"group-id"}'
```

Always use `--account <org>` for organization-scoped Workflow work. Read the live schema with `tools get`, then read resources again after mutation. Publishing, status changes, workflow deletion, and group deletion use the confirmation-code flow.

See [Tools API (v2)](/docs/api-reference/v2-tools) for the full list of available tools.

## Examples

### Bulk tag subscribers

```bash
for email in alice@example.com bob@example.com charlie@example.com; do
  lumail subscribers add-tags "$email" --tags "webinar-2025"
done
```

### Export subscribers as CSV

```bash
lumail tools run list_subscribers \
  --params '{"limit": 1000, "status": "SUBSCRIBED"}' \
  --format csv > subscribers.csv
```

### Quick campaign workflow

```bash
# Create
ID=$(lumail campaigns create --subject "Flash Sale!" --name "Flash Sale" --json | jq -r '.data.campaignId')

# Send
lumail campaigns send "$ID"
```

## Related Documentation

- [TypeScript SDK](/docs/api-reference/sdk) - Programmatic API client
- [API Tokens](/docs/api-reference/api-tokens) - Generate your API key
- [MCP Server](/docs/api-reference/mcp) - AI agent integration
- [Tools API (v2)](/docs/api-reference/v2-tools) - All available tools

---

# MCP Server
Source: https://lumail.io/docs/api-reference/mcp.md

Lumail's Model Context Protocol (MCP) server lets an AI assistant read and manage organization-scoped subscribers, campaigns, Workflows, tags, segments, settings, and analytics.

The tool catalog is generated from the same definitions used by Lumail's in-app agent, CLI, SDK, and Tools API. Use MCP tool discovery as the source of truth instead of relying on a fixed tool count.

## Choose an endpoint

| Client                                  | Endpoint                        | Authentication         | Safety profile                                         |
| --------------------------------------- | ------------------------------- | ---------------------- | ------------------------------------------------------ |
| ChatGPT and Codex                       | `https://lumail.io/mcp`         | Lumail OAuth           | Curated read and draft-write catalog                   |
| Claude, Cursor, and trusted MCP clients | `https://lumail.io/api/mcp/sse` | Organization API token | Full organization tool catalog with confirmation gates |

The OAuth endpoint cannot send, publish, schedule, delete, archive, or unsubscribe. Follow the [ChatGPT and Codex guide](/docs/ai-integration/chatgpt-plugin) for that setup.

The API-token endpoint is intended for trusted clients that need the wider catalog. Every request remains scoped to the organization that owns the token.

## Connect with an API token

Create a token from **Settings → API Tokens**, then add the Streamable HTTP endpoint to your client:

```json
{
  "mcpServers": {
    "lumail": {
      "url": "https://lumail.io/api/mcp/sse",
      "headers": {
        "Authorization": "Bearer lum_your_api_token_here"
      }
    }
  }
}
```

For a stdio-only client, use an HTTP bridge:

```json
{
  "mcpServers": {
    "lumail": {
      "command": "pnpm",
      "args": [
        "dlx",
        "mcp-remote",
        "https://lumail.io/api/mcp/sse",
        "--header",
        "Authorization: Bearer lum_your_api_token_here"
      ]
    }
  }
}
```

Restart the MCP client after changing its configuration.

## Discover resources

After authentication, the server advertises the MCP `resources` capability. Use `resources/list` and `resources/read` to retrieve:

- **Lumail developer and agent start** (`https://lumail.io/`, `text/markdown`) - concise product capabilities and canonical MCP, REST, SDK, CLI, support, and documentation links
- **Lumail OpenAPI 3.1 specification** (`https://lumail.io/openapi.json`, `application/json`) - typed REST operations, bearer authentication, request schemas, response schemas, and structured errors

Every returned URI also resolves over ordinary HTTPS. Resource content is public developer documentation; organization data remains available only through authenticated tools.

## Discover tools dynamically

MCP clients receive the current tool names, descriptions, input schemas, and safety annotations during discovery. The same catalog is available over REST:

```bash
curl https://lumail.io/api/v2/tools \
  -H "Authorization: Bearer lum_your_api_token_here"
```

The `v2` in `/api/v2/tools` is the Tools API version. Workflow tool names are canonical and unversioned.

## Canonical Workflow tools

Lumail has one supported graph-based Workflow engine. Its MCP tools operate on editable drafts, immutable published versions, subscriber runs, goals, exits, and mutual-exclusion groups.

| Area            | Tools                                                                                                                                         |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| Read and create | `list_workflows`, `get_workflow`, `create_workflow`                                                                                           |
| Draft graph     | `configure_workflow_draft`, `update_workflow_draft`                                                                                           |
| Lifecycle       | `publish_workflow`, `update_workflow_status`, `delete_workflow`                                                                               |
| Enrollment      | `add_subscriber_to_workflow`, `add_subscribers_to_workflow`, `remove_subscriber_from_workflow`                                                |
| Runs            | `get_subscriber_workflow_runs`, `fast_forward_workflow_subscriber`                                                                            |
| Groups          | `list_workflow_groups`, `get_workflow_group`, `create_workflow_group`, `update_workflow_group`, `set_workflow_group`, `delete_workflow_group` |

There are no version-suffixed Workflow tools and no step-by-step mutation tools. A Workflow draft is configured as one complete `{ steps, edges }` graph.

### Safe draft flow

1. Call `get_skill` with `{ "type": "workflow" }`.
2. Use `list_workflows` or `create_workflow` to identify the target.
3. Call `get_workflow` immediately before editing and keep its exact `updatedAt`.
4. Resolve referenced tags, senders, snippets, campaigns, fields, goals, and groups with read tools.
5. Call `configure_workflow_draft` once with the complete steps and edges plus `expectedUpdatedAt`.
6. Read the Workflow back, inspect every configured internal email with `get_campaign`, and verify each render with `render_campaign`.

`configure_workflow_draft` creates or updates the internal `WORKFLOW` campaign owned by each EMAIL step. Do not call `create_campaign` for Workflow emails and do not attach broadcast campaigns directly.

Draft configuration never publishes or sends. Publishing and activation are separate, confirmation-gated actions.

## Tool categories

The full catalog also includes tools for:

- Subscribers, composed filters, tags, and custom fields
- Campaign drafting, rendering, history, scheduling, and analytics
- Segments and organization analytics
- Email senders, domains, deliverability, and suppression management
- Snippets, variables, images, imports, and exports
- Transactional email and web content retrieval

Use `get_skill` before specialized operations such as Workflow graph configuration or email copywriting. Pass `{ "type": "docs" }` to list every product docs page, then `{ "type": "docs", "page": "<slug>" }` to read one. Use the schema returned by discovery rather than guessing parameter names.

## Confirmation gates

High-impact tools use a two-call confirmation flow. The first call returns `CONFIRMATION_REQUIRED` with a five-digit `confirmationCode`. Show the proposed action to the user, then repeat the exact same tool call with that code within five minutes.

Confirmation-gated actions include sending or scheduling email, publishing or activating a Workflow, destructive deletion, archiving, and unsubscribe operations. The tool's annotations and first response are authoritative for the current catalog.

Codes are single-use, expire after five minutes, and are bound to the organization, tool, and exact input.

## Operating rules

- Read the current resource immediately before changing it.
- Create campaigns and Workflows as drafts first.
- Never send, publish, activate, schedule, archive, unsubscribe, or delete without explicit user intent.
- Preserve `updatedAt` values for optimistic-concurrency operations.
- Use `edit_campaign` for campaign changes and `configure_workflow_draft` for complete Workflow graphs.
- Read back every mutation before reporting success.
- Keep API tokens secret and out of browser-side code, logs, and prompts.
- Respect organization rate limits and retry `429` responses with backoff.

## Example prompts

```text
Create a welcome Workflow draft with three emails over seven days.
Keep it unpublished, then show me the rendered text and links for every email.
```

```text
List my paused Workflows and explain which published version each one uses.
Do not activate anything.
```

```text
Add the subscriber alex@example.com to the active onboarding Workflow.
Show me the exact action before requesting confirmation.
```

## Troubleshooting

**Tools do not appear:** restart the MCP client, verify the endpoint, and confirm that the token belongs to the intended organization.

**Unauthorized:** create a new organization API token or reconnect the OAuth integration.

**Workflow changed since it was read:** call `get_workflow` again, merge intentionally, and retry with the new `updatedAt`.

**Confirmation required:** repeat the same call with the returned code. Changing another input creates a different confirmation challenge.

## Related

- [Workflow guide](/docs/workflows/workflow)
- [Tools API](/docs/ai-integration/tools-api)
- [Tools API reference](/docs/api-reference/v2-tools)
- [Claude MCP setup](/docs/ai-integration/mcp-claude)
- [Cursor MCP setup](/docs/ai-integration/mcp-cursor)
- [API tokens](/docs/api-reference/api-tokens)

---

# Tools API (v2)
Source: https://lumail.io/docs/api-reference/v2-tools.md

The Tools API provides a unified REST interface to execute any Lumail tool. Instead of learning multiple endpoints, you can use a single pattern to access all functionality.

## Quick Start

All tools are available at `/api/v2/tools/{tool_name}`.

**List all available tools:**

```bash
curl https://lumail.io/api/v2/tools \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

**Execute a tool:**

```bash
curl -X POST https://lumail.io/api/v2/tools/list_subscribers \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"limit": 10}'
```

## Authentication

All requests require a Bearer token in the Authorization header:

```
Authorization: Bearer lum_your_api_token_here
```

Get your API token from **Settings → API Tokens** in your Lumail dashboard.

## Response Format

All tool responses follow this structure:

**Success:**

```json
{
  "success": true,
  "data": {
    /* tool-specific response */
  }
}
```

**Error:**

```json
{
  "success": false,
  "error": "Error message"
}
```

## Dangerous Action Confirmation

Publishing, activating, archiving, deleting, sending, or otherwise high-impact tools use a two-call confirmation flow. The first request returns `CONFIRMATION_REQUIRED` and a five-digit `confirmationCode`. Repeat the exact same request with that code within five minutes to execute it.

Draft-only tools such as `configure_workflow_draft` do not require confirmation because they never publish or send.

## Rate Limits

Rate limits apply per organization based on your plan:

| Plan       | Requests/minute |
| ---------- | --------------- |
| Free       | 100             |
| Premium    | 700             |
| Enterprise | 2,000           |

## Available Tools (95 total)

| Tool                              | Description                                                          |
| --------------------------------- | -------------------------------------------------------------------- |
| `list_tags`                       | List all tags in your organization.                                  |
| `create_tag`                      | Create a new tag in your organization.                               |
| `delete_tag`                      | Delete a tag by ID or name. This will remove the tag from al...      |
| `get_or_create_tags`              | Get existing tags or create new ones by name. Tags are label...      |
| `list_subscribers`                | List subscribers in your organization. Filter by tag, status...      |
| `query_subscribers`               | List subscribers matching composed filters - the same engine...      |
| `count_subscribers`               | Count subscribers matching composed filters - same engine as...      |
| `get_subscriber`                  | Get detailed information about a specific subscriber by ID o...      |
| `add_subscriber`                  | Add a new subscriber or update an existing one. Supports tag...      |
| `update_subscriber`               | Update a subscriber by ID or email. Omitted resubscribe does...      |
| `unsubscribe`                     | Unsubscribe a subscriber by ID or email. The subscriber will...      |
| `bulk_add_tags`                   | Add one or more tags to multiple subscribers at once. Max 10...      |
| `bulk_remove_tags`                | Remove one or more tags from multiple subscribers. Max 100 s...      |
| `create_bulk_operation`           | Queue a durable subscriber bulk action and return an operati...      |
| `get_bulk_operation`              | Read one durable subscriber bulk operation status and counts.        |
| `list_bulk_operations`            | List the 20 most recent durable subscriber bulk operations.          |
| `create_event`                    | Create a custom event for a subscriber. Useful for tracking ...      |
| `list_subscriber_events`          | List events for a subscriber. Use this to check if an email ...      |
| `get_subscriber_emails`           | Get emails sent to a subscriber with their delivery status. ...      |
| `list_campaigns`                  | List email campaigns in your organization with optional filt...      |
| `create_campaign`                 | Create a new email campaign. The campaign will be created in...      |
| `get_campaign`                    | Get detailed information about a specific campaign by ID.            |
| `get_campaign_progress`           | Get the sending progress of a campaign that is currently bei...      |
| `get_campaign_analytics`          | Get detailed analytics for a sent campaign including opens, ...      |
| `duplicate_campaign`              | Create a complete copy of a campaign including all content a...      |
| `edit_campaign`                   | Modify a broadcast campaign (name, subject, preview, senderI...      |
| `auto_fit_campaign_images`        | Apply the editor's magic-wand auto-fit to a draft campaign's...      |
| `get_available_filters`           | Get the machine-readable filter schema (types, operators, re...      |
| `update_campaign_filters`         | Update the recipient filters of a DRAFT campaign. Filters ar...      |
| `send_test_email`                 | Send a test email for a campaign to one or more email addres...      |
| `render_campaign`                 | Render a campaign to its final HTML and plain text exactly a...      |
| `schedule_campaign`               | Schedule a DRAFT campaign (status becomes SCHEDULED). The ca...      |
| `cancel_campaign_schedule`        | Cancel a scheduled campaign, reverting it back to DRAFT stat...      |
| `list_campaign_history`           | List version history entries for a campaign. Returns entries...      |
| `get_campaign_history_entry`      | Get a specific campaign history entry with full content. Use...      |
| `restore_campaign_history`        | Restore a campaign to a previous version from its history. T...      |
| `archive_campaign`                | Archive a DRAFT campaign. Archived campaigns are hidden from...      |
| `unarchive_campaign`              | Restore an ARCHIVED campaign back to DRAFT status so it can ...      |
| `list_workflows`                  | List graph-based workflow automations.                               |
| `get_workflow`                    | Get a Workflow draft graph, published version information, s...      |
| `create_workflow`                 | Create a graph-based Workflow draft. It must be configured a...      |
| `configure_workflow_draft`        | Configure an entire Workflow draft (steps, edges, waits, bra...      |
| `update_workflow_draft`           | Replace a Workflow draft graph atomically. The full &#123;steps, ... |
| `publish_workflow`                | Validate and publish the current Workflow draft as a new imm...      |
| `update_workflow_status`          | Activate, pause, or archive a published Workflow.                    |
| `add_subscriber_to_workflow`      | Manually enroll a subscriber in an active, published Workflo...      |
| `add_subscribers_to_workflow`     | Manually enroll a filtered subscriber set — same as Select a...      |
| `remove_subscriber_from_workflow` | Cancel active Workflow runs for a subscriber.                        |
| `get_subscriber_workflow_runs`    | List a subscriber's workflow runs: status, current step, nex...      |
| `fast_forward_workflow_subscriber` | Expire the current WAIT or WAIT_UNTIL and resume the runner now.   |
| `delete_workflow`                 | Delete an unused Workflow draft, or archive it when publishe...      |
| `list_workflow_groups`            | List Workflow groups with their conflict strategy and assign...      |
| `get_workflow_group`              | Get a Workflow group, its assigned workflows, and current ac...      |
| `create_workflow_group`           | Create a Workflow group. The operation is idempotent when a ...      |
| `update_workflow_group`           | Rename a Workflow group or change its conflict strategy for ...      |
| `set_workflow_group`              | Assign a Workflow to an existing group, or detach it with gr...      |
| `delete_workflow_group`           | Permanently delete a Workflow group after a fresh read. Refu...      |
| `list_segments`                   | List saved subscriber segments in your organization.                 |
| `get_segment`                     | Get detailed information about a specific segment including ...      |
| `create_segment`                  | Create a new subscriber segment with filter configuration.           |
| `update_segment`                  | Update a segment's name or filter configuration.                     |
| `delete_segment`                  | Permanently delete a segment.                                        |
| `duplicate_segment`               | Create a copy of a segment with all its filters.                     |
| `get_org_stats`                   | Get overall organization statistics including subscriber cou...      |
| `get_subscriber_growth`           | Analyze subscriber growth over a specified time period. Show...      |
| `get_subscriber_stats`            | Get subscriber growth and status statistics for your organiz...      |
| `get_subscribers_by_tag`          | Get all subscribers that have a specific tag. Useful for und...      |
| `count_subscribers_by_status`     | Get subscriber count by status. Optionally filter by a speci...      |
| `get_writing_style`               | Get the organization's default writing style for content gen...      |
| `update_writing_style`            | Modify writing style settings (persona, audience, vocabulary...      |
| `get_email_senders`               | List all configured email senders (from addresses) for this ...      |
| `get_custom_fields`               | List all custom subscriber field definitions for this organi...      |
| `create_custom_field`             | Create a custom subscriber field definition by name.                 |
| `rename_custom_field`             | Rename a custom subscriber field definition by id.                   |
| `delete_custom_field`             | Delete a custom subscriber field definition by id.                   |
| `get_email_snippets`              | List email snippets (reusable content blocks) for this organ...      |
| `get_email_snippet`               | Get a single email snippet by id (snp\_...) including its ful...     |
| `create_email_snippet`            | Create a new email snippet (reusable content block). Content...      |
| `update_email_snippet`            | Update an email snippet by id (snp\_...): name, description, ...     |
| `archive_email_snippet`           | Archive an email snippet by id (snp\_...). Archived snippets ...     |
| `unarchive_email_snippet`         | Restore an archived email snippet by id (snp\_...).                  |
| `update_organization_settings`    | Update organization name and timezone                                |
| `fetch_web_page`                  | Fetch a web page and convert to markdown format                      |
| `send_email`                      | Send a transactional email to a subscriber. The 'from' addre...      |
| `get_available_variables`         | Get all available variables for email content (built-in, org...      |
| `get_skill`                       | Get task-specific documentation. Call it BEFORE writing cont...      |
| `list_domains`                    | List the organization's email sending domains with their ver...      |
| `create_domain`                   | Register a new email sending domain and return the DNS recor...      |
| `get_domain_dns_records`          | Get a domain's DNS records with their per-record verificatio...      |
| `verify_domain`                   | Re-check a domain's verification, DKIM and MAIL FROM state a...      |
| `create_email_sender`             | Create an email sender (from-address) as localPart@domain on...      |
| `list_web_domains`                | List the organization's web domains (capture pages and click...      |
| `get_web_domain`                  | Retrieve one web domain by id (wdom_...) or hostname, includ...      |
| `verify_web_domain`               | Re-check a web domain's Cloudflare custom-hostname verificat...      |
| `get_deliverability_health`       | Assess the organization's deliverability health (sender repu...      |
| `list_suppressed_subscribers`     | List suppressed subscribers (BOUNCED and/or COMPLAINED), mos...      |
| `reactivate_subscriber`           | Reactivate a suppressed subscriber (BOUNCED, COMPLAINED, or ...      |
| `upload_image`                    | Import an image from a public http/https URL into your organ...      |
| `list_images`                     | Search the organization media library. The query matches bot...      |
| `describe_image`                  | Set or update the description of an image already in the med...      |
| `import_subscribers`              | Bulk import subscribers (up to 500 per call). Existing subsc...      |
| `export_subscribers`              | Export a small subscriber sample inline (default 1000, max 10000).   |
| `create_subscriber_export`        | Queue a durable CSV export and return an exportId.                   |
| `get_subscriber_export`           | Poll a durable subscriber CSV export until it is done.               |
| `list_subscriber_exports`         | List the 10 most recent subscriber CSV exports.                      |

---

## Subscriber Tools

### List Subscribers

**Endpoint:** `POST /api/v2/tools/list_subscribers`

List subscribers in your organization. Filter by tag, status, or search query. Returns results with email, name, status, tags, sorted by creation date descending.

**Parameters:**

| Parameter | Type                                                                                                       | Required | Description                                              |
| --------- | ---------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------- |
| `limit`   | integer                                                                                                    | No       | (default: `50`)                                          |
| `status`  | "all" \| "SUBSCRIBED" \| "UNSUBSCRIBED" \| "PENDING_CONFIRMATION" \| "BOUNCED" \| "BANNED" \| "COMPLAINED" \| "TRANSACTIONAL" | No       | (default: `"all"`)                                       |
| `tag`     | string                                                                                                     | No       | Filter by tag name (case-insensitive)                    |
| `query`   | string                                                                                                     | No       |                                                          |
| `cursor`  | string                                                                                                     | No       | Cursor for pagination (subscriber ID from previous page) |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/list_subscribers \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Query Subscribers

**Endpoint:** `POST /api/v2/tools/query_subscribers`

List subscribers matching composed filters - the same engine the UI uses, cursor-paginated. Use it instead of `list_subscribers` when you need conditional filters (e.g. clicked link X in campaign Y AND is BANNED). Call get_skill(&#123; type: "filters" &#125;) for the filter shapes and operators.

**Parameters:**

| Parameter | Type                                                                                                       | Required | Description                                                                                                                                                                                                                        |
| --------- | ---------------------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `filters` | array                                                                                                      | No       | Array of filter objects - same shape as update_campaign_filters and create_segment. See get_skill(&#123; type: "filters" &#125;). (default: `[]`)                                                                                  |
| `status`  | "PENDING_CONFIRMATION" \| "SUBSCRIBED" \| "UNSUBSCRIBED" \| "BOUNCED" \| "BANNED" \| "COMPLAINED" \| "TRANSACTIONAL" \| "ALL" | No       | Subscriber status filter - applied in addition to the filters array. Defaults to ALL. (default: `"ALL"`)                                                                                                                           |
| `query`   | string                                                                                                     | No       | Free-text search across email, name, and tag name.                                                                                                                                                                                 |
| `limit`   | integer                                                                                                    | No       | (default: `50`)                                                                                                                                                                                                                    |
| `cursor`  | string                                                                                                     | No       | Cursor for pagination (subscriber ID from previous page).                                                                                                                                                                          |
| `fields`  | array                                                                                                      | No       | Subset of fields to include per subscriber. Defaults to id, email, name, status, createdAt, tags. Available: id, email, name, phone, status, createdAt, updatedAt, tags, customFields, revenue, emailCount, openCount, clickCount. |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/query_subscribers \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Count Subscribers

**Endpoint:** `POST /api/v2/tools/count_subscribers`

Count subscribers matching composed filters - same engine as query_subscribers but returns only the total. Useful for previews before scheduling a campaign or building a segment.

**Parameters:**

| Parameter | Type                                                                                                       | Required | Description                                                                                              |
| --------- | ---------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------- |
| `filters` | array                                                                                                      | No       | Array of filter objects - same shape as update_campaign_filters and create_segment. (default: `[]`)      |
| `status`  | "PENDING_CONFIRMATION" \| "SUBSCRIBED" \| "UNSUBSCRIBED" \| "BOUNCED" \| "BANNED" \| "COMPLAINED" \| "TRANSACTIONAL" \| "ALL" | No       | Subscriber status filter - applied in addition to the filters array. Defaults to ALL. (default: `"ALL"`) |
| `query`   | string                                                                                                     | No       | Free-text search across email, name, and tag name.                                                       |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/count_subscribers \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Get Subscriber

**Endpoint:** `POST /api/v2/tools/get_subscriber`

Get detailed information about a specific subscriber by ID or email.

**Parameters:**

| Parameter      | Type   | Required | Description |
| -------------- | ------ | -------- | ----------- |
| `subscriberId` | string | No       |             |
| `email`        | string | No       |             |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/get_subscriber \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Add Subscriber

**Endpoint:** `POST /api/v2/tools/add_subscriber`

Add a new subscriber or update an existing one. Supports tags and custom fields.

**Parameters:**

| Parameter     | Type    | Required | Description        |
| ------------- | ------- | -------- | ------------------ |
| `email`            | string  | Yes      |                    |
| `name`             | string  | No       |                    |
| `phone`            | string  | No       |                    |
| `tags`             | array   | No       | (default: `[]`)    |
| `fields`           | object  | No       |                    |
| `replaceTags`      | boolean | No       | (default: `false`) |
| `resubscribe`      | boolean | No       | Re-subscribe if they were unsubscribed. (default: `true`) |
| `triggerWorkflows` | boolean | No       | Fire matching workflows. (default: `true`). Pass `false` to skip. |
| `skipDoubleOptIn`  | boolean | No       | Create or promote as `SUBSCRIBED` even if organization double opt-in is on. Default `false`. Does not resubscribe `UNSUBSCRIBED` alone, and does not write confirmation evidence. |
| `ipAddress`        | string  | No       |                    |
| `country`          | string  | No       | ISO-3166 alpha-2   |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/add_subscriber \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Update Subscriber

**Endpoint:** `POST /api/v2/tools/update_subscriber`

Update a subscriber by ID or email. Same fields as `add_subscriber`, but omitted `resubscribe` / `triggerWorkflows` are left unset (a name-only patch does **not** resubscribe).

**Parameters:**

| Parameter          | Type    | Required | Description |
| ------------------ | ------- | -------- | ----------- |
| `subscriber`       | string  | Yes      | ID or email |
| `email`            | string  | No       |             |
| `name`             | string  | No       |             |
| `phone`            | string  | No       |             |
| `tags`             | array   | No       |             |
| `fields`           | object  | No       |             |
| `replaceTags`      | boolean | No       |             |
| `resubscribe`      | boolean | No       | Default unset / false. Pass `true` to re-subscribe. |
| `triggerWorkflows` | boolean | No       | Pass `false` to skip automations. |
| `skipDoubleOptIn`  | boolean | No       | Promote pending contacts to `SUBSCRIBED` before tag events. Default `false`. |
| `ipAddress`        | string  | No       |             |
| `country`          | string  | No       | ISO-3166 alpha-2 |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/update_subscriber \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"subscriber":"user@example.com","name":"Ada"}'
```

### Unsubscribe

**Endpoint:** `POST /api/v2/tools/unsubscribe`

Unsubscribe a subscriber by ID or email. The subscriber will no longer receive emails.

**Parameters:**

| Parameter      | Type   | Required | Description |
| -------------- | ------ | -------- | ----------- |
| `subscriberId` | string | No       |             |
| `email`        | string | No       |             |
| `reason`       | string | No       |             |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/unsubscribe \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Bulk Add Tags

**Endpoint:** `POST /api/v2/tools/bulk_add_tags`

Add one or more tags to multiple subscribers at once. Max 100 subscribers and 20 tags per call. Emits a tag.added event for each newly added tag, so tag-based workflow triggers (including Workflow event triggers) will fire exactly like when tags are added from the app.

**Parameters:**

| Parameter       | Type  | Required | Description |
| --------------- | ----- | -------- | ----------- |
| `subscriberIds` | array | Yes      |             |
| `tagNames`      | array | Yes      |             |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/bulk_add_tags \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Bulk Remove Tags

**Endpoint:** `POST /api/v2/tools/bulk_remove_tags`

Remove one or more tags from multiple subscribers. Max 100 subscribers and 20 tags per call. Emits a tag.removed event for each removed tag, so tag-based workflow triggers and exit rules react exactly like when tags are removed from the app.

**Parameters:**

| Parameter       | Type  | Required | Description |
| --------------- | ----- | -------- | ----------- |
| `subscriberIds` | array | Yes      |             |
| `tagNames`      | array | Yes      |             |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/bulk_remove_tags \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Create Bulk Operation

**Endpoint:** `POST /api/v2/tools/create_bulk_operation`

Queue the same durable subscriber bulk job as the subscribers table. Returns immediately with `operationId`. Poll `get_bulk_operation` until `done` is true.

**Parameters:**

| Parameter           | Type   | Required | Description                                      |
| ------------------- | ------ | -------- | ------------------------------------------------ |
| `action`            | string | Yes      | `ADD_TAGS`, `REMOVE_TAGS`, `SUBSCRIBE`, `UNSUBSCRIBE`, `BAN`, `ADD_TO_WORKFLOW`, `REMOVE_FROM_WORKFLOW` |
| `subscriberIds`     | array  | No       | Explicit IDs. Omit to use filters/query/status.  |
| `filters`           | array  | No       | Same filter objects as `query_subscribers`.      |
| `query`             | string | No       | Free-text search.                                |
| `status`            | string | No       | Subscriber status filter.                        |
| `tags`              | array  | No       | Required for tag actions.                        |
| `workflowId`        | string | No       | Required for workflow actions.                   |
| `stepId`            | string | No       | Starting step for `ADD_TO_WORKFLOW`.             |
| `reason`            | string | No       | Optional unsubscribe reason.                     |
| `idempotencyKey`    | string | No       | Reuse to avoid double-queue.                     |
| `sourceOperationId` | string | No       | Reuse a previous operation audience.             |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/create_bulk_operation \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"action":"ADD_TAGS","subscriberIds":["sub_1"],"tags":["vip"]}'
```

### Get Bulk Operation

**Endpoint:** `POST /api/v2/tools/get_bulk_operation`

Read one durable subscriber bulk operation: status, progress counts, and last error.

**Parameters:**

| Parameter     | Type   | Required | Description |
| ------------- | ------ | -------- | ----------- |
| `operationId` | string | Yes      |             |

### List Bulk Operations

**Endpoint:** `POST /api/v2/tools/list_bulk_operations`

List the 20 most recent visible durable subscriber bulk operations.

### Create Event

**Endpoint:** `POST /api/v2/tools/create_event`

Create a custom event for a subscriber. Useful for tracking purchases, conversions, or other actions.

**Parameters:**

| Parameter      | Type                                        | Required | Description        |
| -------------- | ------------------------------------------- | -------- | ------------------ |
| `subscriberId` | string                                      | No       |                    |
| `email`        | string                                      | No       |                    |
| `eventType`    | "SUBSCRIBER_PAYMENT" \| "SUBSCRIBER_REFUND" | Yes      |                    |
| `amount`       | number                                      | No       |                    |
| `currency`     | string                                      | No       | (default: `"USD"`) |
| `metadata`     | object                                      | No       |                    |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/create_event \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### List Subscriber Events

**Endpoint:** `POST /api/v2/tools/list_subscriber_events`

List events for a subscriber. Use this to check if an email was delivered, opened, clicked, bounced, etc. Filter by event type to find specific events.

**Parameters:**

| Parameter      | Type                                                                                                                                                                                                                                                                                                                                                                      | Required | Description     |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | --------------- |
| `subscriberId` | string                                                                                                                                                                                                                                                                                                                                                                    | No       |                 |
| `email`        | string                                                                                                                                                                                                                                                                                                                                                                    | No       |                 |
| `eventType`    | "SUBSCRIBED" \| "UNSUBSCRIBED" \| "TAG_ADDED" \| "TAG_REMOVED" \| "EMAIL_OPENED" \| "EMAIL_CLICKED" \| "EMAIL_SENT" \| "EMAIL_DELIVERY_DELAYED" \| "WORKFLOW_STARTED" \| "WORKFLOW_COMPLETED" \| "WORKFLOW_CANCELED" \| "FIELD_UPDATED" \| "EMAIL_BOUNCED" \| "EMAIL_COMPLAINED" \| "EMAIL_RECEIVED" \| "WEBHOOK_EXECUTED" \| "SUBSCRIBER_PAYMENT" \| "SUBSCRIBER_REFUND" | No       |                 |
| `limit`        | integer                                                                                                                                                                                                                                                                                                                                                                   | No       | (default: `50`) |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/list_subscriber_events \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Get Subscriber Emails

**Endpoint:** `POST /api/v2/tools/get_subscriber_emails`

Get emails sent to a subscriber with their delivery status. Use this to check if emails were delivered, bounced, or are pending. Shows campaign name, subject, and detailed status.

**Parameters:**

| Parameter      | Type                                                            | Required | Description     |
| -------------- | --------------------------------------------------------------- | -------- | --------------- |
| `subscriberId` | string                                                          | No       |                 |
| `email`        | string                                                          | No       |                 |
| `status`       | "PENDING" \| "SENT" \| "DELIVERED" \| "BOUNCED" \| "COMPLAINED" | No       |                 |
| `limit`        | integer                                                         | No       | (default: `20`) |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/get_subscriber_emails \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

## Tag Tools

### List Tags

**Endpoint:** `POST /api/v2/tools/list_tags`

List all tags in your organization.

**Parameters:**

No parameters required.

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/list_tags \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Create Tag

**Endpoint:** `POST /api/v2/tools/create_tag`

Create a new tag in your organization.

**Parameters:**

| Parameter | Type   | Required | Description |
| --------- | ------ | -------- | ----------- |
| `name`    | string | Yes      |             |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/create_tag \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Delete Tag

**Endpoint:** `POST /api/v2/tools/delete_tag`

Delete a tag by ID or name. This will remove the tag from all subscribers.

**Parameters:**

| Parameter | Type   | Required | Description |
| --------- | ------ | -------- | ----------- |
| `tagId`   | string | No       |             |
| `name`    | string | No       |             |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/delete_tag \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Get Or Create Tags

**Endpoint:** `POST /api/v2/tools/get_or_create_tags`

Get existing tags or create new ones by name. Tags are labels used to categorize subscribers and trigger workflows. This tool automatically creates any tags that don't exist yet.

**Parameters:**

| Parameter | Type  | Required | Description                         |
| --------- | ----- | -------- | ----------------------------------- |
| `names`   | array | Yes      | Array of tag names to get or create |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/get_or_create_tags \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

## Campaign Tools

### List Campaigns

**Endpoint:** `POST /api/v2/tools/list_campaigns`

List email campaigns in your organization with optional filtering by status and search.

**Parameters:**

| Parameter | Type                                                                    | Required | Description     |
| --------- | ----------------------------------------------------------------------- | -------- | --------------- |
| `limit`   | integer                                                                 | No       | (default: `20`) |
| `page`    | integer                                                                 | No       | (default: `1`)  |
| `status`  | "DRAFT" \| "SCHEDULED" \| "SENDING" \| "SENT" \| "ARCHIVED" \| "FAILED" | No       |                 |
| `query`   | string                                                                  | No       |                 |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/list_campaigns \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Create Campaign

**Endpoint:** `POST /api/v2/tools/create_campaign`

Create a new email campaign. The campaign will be created in DRAFT status. If no senderId is provided, it will use the default sender, then last used sender, then any available sender.

**Parameters:**

| Parameter     | Type                             | Required | Description                                                                                                                                                                                                               |
| ------------- | -------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `subject`     | string                           | Yes      | Email subject line (required)                                                                                                                                                                                             |
| `name`        | string                           | No       | Campaign name (default: `"Untitled"`)                                                                                                                                                                                     |
| `preview`     | unknown                          | No       | Preview/preheader text shown in email clients                                                                                                                                                                             |
| `content`     | unknown                          | No       | TipTap JSON content (object or JSON-stringified). If not provided, a default template with Hello World and unsubscribe link is used. Call get_skill(&#123; type: "campaign" &#125;) for the full TipTap format reference. |
| `contentType` | "MAILY" \| "PLATE" \| "MARKDOWN" | No       | Content format type (default: `"MAILY"`)                                                                                                                                                                                  |
| `senderId`    | string                           | No       | Email sender ID. If not provided, uses default sender. Call get_email_senders to list available senders.                                                                                                                  |
| `replyTo`     | unknown                          | No       | Reply-to email address                                                                                                                                                                                                    |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/create_campaign \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Get Campaign

**Endpoint:** `POST /api/v2/tools/get_campaign`

Get detailed information about a specific campaign by ID. Returns `senderId`, `sender` (`id`, `name`, `email`), `replyTo`, `recipientFilters`, `content`, `preview`, and `contentType`.

**Parameters:**

| Parameter    | Type   | Required | Description |
| ------------ | ------ | -------- | ----------- |
| `campaignId` | string | Yes      |             |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/get_campaign \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Get Campaign Progress

**Endpoint:** `POST /api/v2/tools/get_campaign_progress`

Get the sending progress of a campaign that is currently being sent.

**Parameters:**

| Parameter    | Type   | Required | Description |
| ------------ | ------ | -------- | ----------- |
| `campaignId` | string | Yes      |             |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/get_campaign_progress \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Get Campaign Analytics

**Endpoint:** `POST /api/v2/tools/get_campaign_analytics`

Get detailed analytics for a sent campaign including opens, clicks, and link performance.

**Parameters:**

| Parameter    | Type   | Required | Description |
| ------------ | ------ | -------- | ----------- |
| `campaignId` | string | Yes      |             |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/get_campaign_analytics \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Duplicate Campaign

**Endpoint:** `POST /api/v2/tools/duplicate_campaign`

Create a complete copy of a campaign including all content and settings. The duplicate will be a DRAFT.

**Parameters:**

| Parameter    | Type   | Required | Description |
| ------------ | ------ | -------- | ----------- |
| `campaignId` | string | Yes      |             |
| `newName`    | string | No       |             |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/duplicate_campaign \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Edit Campaign

**Endpoint:** `POST /api/v2/tools/edit_campaign`

Modify a broadcast campaign (name, subject, preview, senderId, replyTo, content). Rejects workflow emails — use configure_workflow_draft.

TWO exclusive modes:

- "content": full TipTap JSON document (&#123; type: "doc", content: [...] &#125;) — for new content or a complete rewrite.
- "operations": array of surgical edits on the existing content (replace_text, insert_node, append_node, prepend_node, remove_node, replace_node) — cheaper for a typo, a link, adding a CTA, removing a paragraph.
  Metadata fields (name, subject, preview, senderId, replyTo) can be combined with either mode. NEVER send content and operations together.

BEFORE writing any content or operations, call get_skill(&#123; type: "campaign" &#125;). It documents the TipTap node types, text marks, "Show if" tag conditions, the exact operations syntax and the mandatory rules. Do not guess the format from memory.

WHEN NOT TO USE:

- Workflow EMAIL steps (type WORKFLOW) -> configure_workflow_draft with an inline email object. Never edit_campaign. get_campaign is read-only.
- Listing/searching campaigns -> use list_campaigns
- Getting campaign details -> use get_campaign
- Updating recipient filters -> use update_campaign_filters

**Parameters:**

| Parameter    | Type    | Required | Description                                                                                                                                                                                                                        |
| ------------ | ------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`         | string  | Yes      | Campaign ID to update                                                                                                                                                                                                              |
| `name`       | string  | No       | New campaign name                                                                                                                                                                                                                  |
| `subject`    | string  | No       | New subject line                                                                                                                                                                                                                   |
| `preview`    | unknown | No       | New preview/preheader text                                                                                                                                                                                                         |
| `content`    | unknown | No       | Full TipTap JSON document (&#123; type: 'doc', content: [...] &#125;). Send as a parsed object (preferred) or a JSON-stringified value - both are accepted. Use for complete content rewrites. Cannot be combined with operations. |
| `operations` | array   | No       | Array of surgical edit operations to apply to existing content. Cannot be combined with content.                                                                                                                                   |
| `senderId`   | string  | No       | Email sender ID. Call get_email_senders to list available senders.                                                                                                                                                                 |
| `replyTo`    | unknown | No       | Reply-to email address. Pass null to clear.                                                                                                                                                                                        |
| `contentType` | "MAILY" \| "PLATE" \| "MARKDOWN" | No       | Content format type.                                                                                                                                                                                                               |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/edit_campaign \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Auto Fit Campaign Images

**Endpoint:** `POST /api/v2/tools/auto_fit_campaign_images`

Apply the editor's magic-wand auto-fit to a draft campaign's images: reads each image's natural dimensions and fits it without upscaling. Always run with dryRun=true first to inspect the proposed sizes. Call get_skill(&#123; type: "images" &#125;) for the full sequence, sizing rules and safety limits.

**Parameters:**

| Parameter      | Type    | Required | Description                                                                                                       |
| -------------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
| `campaignId`   | string  | Yes      | Campaign or internal workflow email ID.                                                                           |
| `imageIndexes` | array   | No       | Optional image indexes from this tool's dry-run output. Omit to auto-fit every image in document traversal order. |
| `maxWidth`     | integer | No       | (default: `600`)                                                                                                  |
| `maxHeight`    | integer | No       | (default: `400`)                                                                                                  |
| `dryRun`       | boolean | No       | Inspect and calculate dimensions without changing the campaign. (default: `false`)                                |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/auto_fit_campaign_images \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Get Available Filters

**Endpoint:** `POST /api/v2/tools/get_available_filters`

Get the machine-readable filter schema (types, operators, required fields) used by update_campaign_filters, query_subscribers and segments. Prefer get_skill(&#123; type: 'filters' &#125;), which also documents how to resolve each ID.

**Parameters:**

No parameters required.

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/get_available_filters \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Update Campaign Filters

**Endpoint:** `POST /api/v2/tools/update_campaign_filters`

Update the recipient filters of a DRAFT campaign. Filters are combined with AND. Call get_skill(&#123; type: 'filters' &#125;) first for the filter shapes, operators and how to resolve each ID.

**Parameters:**

| Parameter    | Type   | Required | Description               |
| ------------ | ------ | -------- | ------------------------- |
| `campaignId` | string | Yes      | Campaign ID to update     |
| `filters`    | array  | Yes      | Array of filters to apply |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/update_campaign_filters \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Send Test Email

**Endpoint:** `POST /api/v2/tools/send_test_email`

Send a test email for a campaign to one or more email addresses (max 5). The test email will have '[Test]' prefix in the subject. Test emails are not tracked in analytics.

**Parameters:**

| Parameter    | Type   | Required | Description                           |
| ------------ | ------ | -------- | ------------------------------------- |
| `campaignId` | string | Yes      | Campaign ID to send test for          |
| `emails`     | array  | Yes      | Email addresses to send test to (1-5) |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/send_test_email \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Render Campaign

**Endpoint:** `POST /api/v2/tools/render_campaign`

Render a campaign to its final HTML and plain text exactly as delivered (snippets inlined, variables resolved, unsubscribe footer added). Nothing is sent. Use it to self-verify content before schedule_campaign. Pass subscriberId to resolve variables and "Show if" conditions for that subscriber; otherwise all conditional blocks are shown.

**Parameters:**

| Parameter      | Type                       | Required | Description                                                            |
| -------------- | -------------------------- | -------- | ---------------------------------------------------------------------- |
| `campaignId`   | string                     | Yes      | Campaign ID to render                                                  |
| `subscriberId` | string                     | No       | Optional subscriber ID to resolve variables and conditional blocks for |
| `format`       | "html" \| "text" \| "both" | No       | Which output to return (default: both)                                 |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/render_campaign \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Schedule Campaign

**Endpoint:** `POST /api/v2/tools/schedule_campaign`

Schedule a DRAFT campaign (status becomes SCHEDULED). Campaign AI findings and evaluator outages do not block HUMAN_VERIFIED, WHITELISTED, or organizations retaining prior human approval. AUTO_VERIFIED and AI_VERIFIED without prior human approval remain subject to blocking checks. The server determines eligibility; other sending restrictions and the unsubscribe link requirement still apply. No date sends immediately; a date without time sends at midnight in the given timezone.

**Parameters:**

| Parameter    | Type    | Required | Description                                                                                           |
| ------------ | ------- | -------- | ----------------------------------------------------------------------------------------------------- |
| `campaignId` | string  | Yes      | Campaign ID to schedule                                                                               |
| `date`       | string  | No       | Date to schedule (ISO format, e.g. '2025-03-20'). If omitted, sends immediately.                      |
| `hours`      | integer | No       | Hour to send (0-23)                                                                                   |
| `minutes`    | integer | No       | Minutes (0-59)                                                                                        |
| `timezone`   | string  | No       | Timezone for the scheduled time (e.g. 'Europe/Paris', 'America/New_York') (default: `"Europe/Paris"`) |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/schedule_campaign \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Cancel Campaign Schedule

**Endpoint:** `POST /api/v2/tools/cancel_campaign_schedule`

Cancel a scheduled campaign, reverting it back to DRAFT status. Only works on SCHEDULED campaigns (not SENDING or SENT).

**Parameters:**

| Parameter    | Type   | Required | Description                        |
| ------------ | ------ | -------- | ---------------------------------- |
| `campaignId` | string | Yes      | Campaign ID to cancel schedule for |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/cancel_campaign_schedule \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### List Campaign History

**Endpoint:** `POST /api/v2/tools/list_campaign_history`

List version history entries for a campaign. Returns entries sorted by most recent first with cursor-based pagination. Each entry shows when it was saved and the source (autosave, manual, or ai).

**Parameters:**

| Parameter    | Type    | Required | Description                                                       |
| ------------ | ------- | -------- | ----------------------------------------------------------------- |
| `campaignId` | string  | Yes      | Campaign ID to get history for                                    |
| `cursor`     | string  | No       | Cursor for pagination (history entry ID from previous page)       |
| `limit`      | integer | No       | Number of entries to return (default 20, max 100) (default: `20`) |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/list_campaign_history \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Get Campaign History Entry

**Endpoint:** `POST /api/v2/tools/get_campaign_history_entry`

Get a specific campaign history entry with full content. Use this to inspect what the campaign looked like at a specific point in time before restoring it.

**Parameters:**

| Parameter    | Type   | Required | Description                  |
| ------------ | ------ | -------- | ---------------------------- |
| `campaignId` | string | Yes      | Campaign ID                  |
| `historyId`  | string | Yes      | History entry ID to retrieve |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/get_campaign_history_entry \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Restore Campaign History

**Endpoint:** `POST /api/v2/tools/restore_campaign_history`

Restore a campaign to a previous version from its history. This updates the campaign's subject and content to match the selected history entry, and creates a new history entry recording the restoration.

**Parameters:**

| Parameter    | Type   | Required | Description                      |
| ------------ | ------ | -------- | -------------------------------- |
| `campaignId` | string | Yes      | Campaign ID to restore           |
| `historyId`  | string | Yes      | History entry ID to restore from |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/restore_campaign_history \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Archive Campaign

**Endpoint:** `POST /api/v2/tools/archive_campaign`

Archive a DRAFT campaign. Archived campaigns are hidden from the main campaign list but can be restored with unarchive_campaign. Only DRAFT campaigns can be archived.

**Parameters:**

| Parameter    | Type   | Required | Description            |
| ------------ | ------ | -------- | ---------------------- |
| `campaignId` | string | Yes      | Campaign ID to archive |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/archive_campaign \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Unarchive Campaign

**Endpoint:** `POST /api/v2/tools/unarchive_campaign`

Restore an ARCHIVED campaign back to DRAFT status so it can be edited and scheduled again.

**Parameters:**

| Parameter    | Type   | Required | Description              |
| ------------ | ------ | -------- | ------------------------ |
| `campaignId` | string | Yes      | Campaign ID to unarchive |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/unarchive_campaign \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

## Workflow Tools

### List Workflows

**Endpoint:** `POST /api/v2/tools/list_workflows`

List graph-based workflow automations.

**Parameters:**

| Parameter | Type    | Required | Description     |
| --------- | ------- | -------- | --------------- |
| `limit`   | integer | No       | (default: `20`) |
| `query`   | string  | No       |                 |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/list_workflows \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Get Workflow

**Endpoint:** `POST /api/v2/tools/get_workflow`

Get a Workflow draft graph, published version information, settings, and active subscriber count.

**Parameters:**

| Parameter    | Type   | Required | Description |
| ------------ | ------ | -------- | ----------- |
| `workflowId` | string | Yes      |             |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/get_workflow \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Create Workflow

**Endpoint:** `POST /api/v2/tools/create_workflow`

Create a graph-based Workflow draft. It must be configured and published before it can enroll subscribers.

**Parameters:**

| Parameter | Type   | Required | Description |
| --------- | ------ | -------- | ----------- |
| `name`    | string | Yes      |             |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/create_workflow \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Configure Workflow Draft

**Endpoint:** `POST /api/v2/tools/configure_workflow_draft`

Configure an entire Workflow draft (steps, edges, waits, branches, actions, goals, settings) in one atomic operation. Never publishes or sends. Call get_skill(&#123; type: 'workflow' &#125;) then get_workflow first - the skill documents the step types, edges, eventFilters keys and the EMAIL-step rule that replaces create_campaign.

**Parameters:**

| Parameter           | Type   | Required | Description                                       |
| ------------------- | ------ | -------- | ------------------------------------------------- |
| `workflowId`        | string | Yes      |                                                   |
| `expectedUpdatedAt` | string | Yes      | Exact updatedAt returned by get_workflow          |
| `settings`          | object | No       |                                                   |
| `steps`             | array  | Yes      | Complete replacement list of Workflow steps       |
| `edges`             | array  | Yes      | Complete replacement list of directed graph edges |
| `successGoals`      | array  | No       |                                                   |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/configure_workflow_draft \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Update Workflow Draft

**Endpoint:** `POST /api/v2/tools/update_workflow_draft`

Replace a Workflow draft graph atomically. The full &#123;steps, edges&#125; definition is validated before it is saved. Every EMAIL step is ownership-checked; foreign or broadcast campaign IDs are duplicated into dedicated internal WORKFLOW emails before saving.

**Parameters:**

| Parameter           | Type   | Required | Description                                                                                                                                                                                                             |
| ------------------- | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `workflowId`        | string | Yes      |                                                                                                                                                                                                                         |
| `expectedUpdatedAt` | string | Yes      |                                                                                                                                                                                                                         |
| `definition`        | object | Yes      | Complete Workflow graph. Use configure_workflow_draft to add EMAIL steps with inline content. Existing campaign IDs are ownership-checked and duplicated rather than linked when they do not belong to that exact step. |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/update_workflow_draft \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Publish Workflow

**Endpoint:** `POST /api/v2/tools/publish_workflow`

Validate and publish the current Workflow draft as a new immutable version. Activation: publishing never deactivates a live workflow and never re-activates a paused one; pass activate:true to go live, activate:false to publish paused. When omitted, only a first publish (DRAFT) activates.

**Parameters:**

| Parameter    | Type    | Required | Description                                                                                                                         |
| ------------ | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `workflowId` | string  | Yes      |                                                                                                                                     |
| `activate`   | boolean | No       | true: activate after publishing. false: keep the workflow paused. Omitted: first publish activates, a paused workflow stays paused. |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/publish_workflow \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Update Workflow Status

**Endpoint:** `POST /api/v2/tools/update_workflow_status`

Activate, pause, or archive a published Workflow.

**Parameters:**

| Parameter    | Type                               | Required | Description |
| ------------ | ---------------------------------- | -------- | ----------- |
| `workflowId` | string                             | Yes      |             |
| `status`     | "ACTIVE" \| "PAUSED" \| "ARCHIVED" | Yes      |             |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/update_workflow_status \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Add Subscriber To Workflow

**Endpoint:** `POST /api/v2/tools/add_subscriber_to_workflow`

Manually enroll a subscriber in an active, published Workflow.

**Parameters:**

| Parameter      | Type   | Required | Description |
| -------------- | ------ | -------- | ----------- |
| `workflowId`   | string | Yes      |             |
| `subscriberId` | string | No       |             |
| `email`        | string | No       |             |
| `stepId`       | string | No       | Published step to start from |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/add_subscriber_to_workflow \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Add Subscribers To Workflow

**Endpoint:** `POST /api/v2/tools/add_subscribers_to_workflow`

Manually enroll a filtered subscriber set in an active published Workflow — same as Subscribers → Select all → Add to workflow. Call with `dryRun: true` first.

**Parameters:**

| Parameter       | Type    | Required | Description |
| --------------- | ------- | -------- | ----------- |
| `workflowId`    | string  | Yes      |             |
| `stepId`        | string  | No       | Published step to start from |
| `filters`       | array   | No       | Same filters as `query_subscribers` |
| `query`         | string  | No       | Free-text search |
| `status`        | string  | No       | Defaults to `SUBSCRIBED` |
| `subscriberIds` | array   | No       | Explicit IDs instead of filters. Max 500 |
| `dryRun`        | boolean | No       | Preview match count and published steps |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/add_subscribers_to_workflow \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"workflowId":"workflow-id","dryRun":true,"filters":[]}'
```

### Remove Subscriber From Workflow

**Endpoint:** `POST /api/v2/tools/remove_subscriber_from_workflow`

Cancel active Workflow runs for a subscriber.

**Parameters:**

| Parameter      | Type   | Required | Description |
| -------------- | ------ | -------- | ----------- |
| `workflowId`   | string | Yes      |             |
| `subscriberId` | string | No       |             |
| `email`        | string | No       |             |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/remove_subscriber_from_workflow \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Get Subscriber Workflow Runs

**Endpoint:** `POST /api/v2/tools/get_subscriber_workflow_runs`

List a subscriber's workflow runs in this organization: status, current step, and nextActionAt. Active and waiting runs first, then recent completed or failed runs.

**Parameters:**

| Parameter      | Type   | Required | Description |
| -------------- | ------ | -------- | ----------- |
| `subscriberId` | string | No       |             |
| `email`        | string | No       |             |
| `workflowId`   | string | No       | If set, only return runs for this workflow. |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/get_subscriber_workflow_runs \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email":"you@example.com"}'
```

### Fast Forward Workflow Subscriber

**Endpoint:** `POST /api/v2/tools/fast_forward_workflow_subscriber`

Expire the current WAIT or WAIT_UNTIL timer on one subscriber's run and resume the runner immediately. The next EMAIL or ACTION executes for real. One call completes one wait. WAIT_UNTIL follows the TIMEOUT edge.

**Parameters:**

| Parameter               | Type   | Required | Description |
| ----------------------- | ------ | -------- | ----------- |
| `workflowId`            | string | Yes      |             |
| `subscriberId`          | string | No       |             |
| `email`                 | string | No       |             |
| `workflowSubscriberId`  | string | No       | Required when more than one waiting run exists |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/fast_forward_workflow_subscriber \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"workflowId":"workflow-id","email":"you@example.com"}'
```

### Delete Workflow

**Endpoint:** `POST /api/v2/tools/delete_workflow`

Delete an unused Workflow draft, or archive it when published versions or run history must be preserved.

**Parameters:**

| Parameter    | Type   | Required | Description |
| ------------ | ------ | -------- | ----------- |
| `workflowId` | string | Yes      |             |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/delete_workflow \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### List Workflow Groups

**Endpoint:** `POST /api/v2/tools/list_workflow_groups`

List Workflow groups with their conflict strategy and assigned workflow count. Call this before assigning a groupId; never invent a group ID.

**Parameters:**

| Parameter | Type    | Required | Description     |
| --------- | ------- | -------- | --------------- |
| `query`   | string  | No       |                 |
| `limit`   | integer | No       | (default: `50`) |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/list_workflow_groups \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Get Workflow Group

**Endpoint:** `POST /api/v2/tools/get_workflow_group`

Get a Workflow group, its assigned workflows, and current active-run counts.

**Parameters:**

| Parameter | Type   | Required | Description |
| --------- | ------ | -------- | ----------- |
| `groupId` | string | Yes      |             |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/get_workflow_group \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Create Workflow Group

**Endpoint:** `POST /api/v2/tools/create_workflow_group`

Create a Workflow group. The operation is idempotent when a case-insensitive name match already has the requested strategy.

**Parameters:**

| Parameter          | Type                                                | Required | Description                    |
| ------------------ | --------------------------------------------------- | -------- | ------------------------------ |
| `name`             | string                                              | Yes      |                                |
| `conflictStrategy` | "CANCEL_EXISTING" \| "SKIP_NEW" \| "RUN_FIRST_STEP" | No       | (default: `"CANCEL_EXISTING"`) |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/create_workflow_group \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Update Workflow Group

**Endpoint:** `POST /api/v2/tools/update_workflow_group`

Rename a Workflow group or change its conflict strategy for future enrollments. Requires a fresh get_workflow_group read and optimistic concurrency.

**Parameters:**

| Parameter           | Type                                                | Required | Description |
| ------------------- | --------------------------------------------------- | -------- | ----------- |
| `groupId`           | string                                              | Yes      |             |
| `expectedUpdatedAt` | string                                              | Yes      |             |
| `name`              | string                                              | No       |             |
| `conflictStrategy`  | "CANCEL_EXISTING" \| "SKIP_NEW" \| "RUN_FIRST_STEP" | No       |             |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/update_workflow_group \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Set Workflow Group

**Endpoint:** `POST /api/v2/tools/set_workflow_group`

Assign a Workflow to an existing group, or detach it with groupId:null, without replacing its graph or publishing. The change affects future enrollments only.

**Parameters:**

| Parameter           | Type    | Required | Description |
| ------------------- | ------- | -------- | ----------- |
| `workflowId`        | string  | Yes      |             |
| `expectedUpdatedAt` | string  | Yes      |             |
| `groupId`           | unknown | Yes      |             |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/set_workflow_group \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Delete Workflow Group

**Endpoint:** `POST /api/v2/tools/delete_workflow_group`

Permanently delete a Workflow group after a fresh read. Refuses to delete an assigned group unless unassignWorkflows:true is explicit.

**Parameters:**

| Parameter           | Type    | Required | Description        |
| ------------------- | ------- | -------- | ------------------ |
| `groupId`           | string  | Yes      |                    |
| `expectedName`      | string  | Yes      |                    |
| `expectedUpdatedAt` | string  | Yes      |                    |
| `unassignWorkflows` | boolean | No       | (default: `false`) |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/delete_workflow_group \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

## Segment Tools

### List Segments

**Endpoint:** `POST /api/v2/tools/list_segments`

List saved subscriber segments in your organization.

**Parameters:**

| Parameter | Type    | Required | Description     |
| --------- | ------- | -------- | --------------- |
| `limit`   | integer | No       | (default: `20`) |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/list_segments \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Get Segment

**Endpoint:** `POST /api/v2/tools/get_segment`

Get detailed information about a specific segment including all filter configurations.

**Parameters:**

| Parameter   | Type   | Required | Description |
| ----------- | ------ | -------- | ----------- |
| `segmentId` | string | Yes      |             |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/get_segment \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Create Segment

**Endpoint:** `POST /api/v2/tools/create_segment`

Create a new subscriber segment with filter configuration.

**Parameters:**

| Parameter | Type   | Required | Description     |
| --------- | ------ | -------- | --------------- |
| `name`    | string | Yes      |                 |
| `filters` | array  | No       | (default: `[]`) |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/create_segment \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Update Segment

**Endpoint:** `POST /api/v2/tools/update_segment`

Update a segment's name or filter configuration.

**Parameters:**

| Parameter   | Type   | Required | Description |
| ----------- | ------ | -------- | ----------- |
| `segmentId` | string | Yes      |             |
| `name`      | string | No       |             |
| `filters`   | array  | No       |             |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/update_segment \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Delete Segment

**Endpoint:** `POST /api/v2/tools/delete_segment`

Permanently delete a segment.

**Parameters:**

| Parameter   | Type   | Required | Description |
| ----------- | ------ | -------- | ----------- |
| `segmentId` | string | Yes      |             |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/delete_segment \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Duplicate Segment

**Endpoint:** `POST /api/v2/tools/duplicate_segment`

Create a copy of a segment with all its filters.

**Parameters:**

| Parameter   | Type   | Required | Description |
| ----------- | ------ | -------- | ----------- |
| `segmentId` | string | Yes      |             |
| `newName`   | string | No       |             |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/duplicate_segment \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

## Analytics Tools

### Get Org Stats

**Endpoint:** `POST /api/v2/tools/get_org_stats`

Get overall organization statistics including subscriber counts, campaign metrics, and resource totals.

**Parameters:**

No parameters required.

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/get_org_stats \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Get Subscriber Growth

**Endpoint:** `POST /api/v2/tools/get_subscriber_growth`

Analyze subscriber growth over a specified time period. Shows new subscribers, unsubscribes, and net growth.

**Parameters:**

| Parameter | Type    | Required | Description     |
| --------- | ------- | -------- | --------------- |
| `days`    | integer | No       | (default: `30`) |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/get_subscriber_growth \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Get Subscriber Stats

**Endpoint:** `POST /api/v2/tools/get_subscriber_stats`

Get subscriber growth and status statistics for your organization.

**Parameters:**

No parameters required.

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/get_subscriber_stats \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Get Subscribers By Tag

**Endpoint:** `POST /api/v2/tools/get_subscribers_by_tag`

Get all subscribers that have a specific tag. Useful for understanding tag usage.

**Parameters:**

| Parameter | Type    | Required | Description     |
| --------- | ------- | -------- | --------------- |
| `tagName` | string  | Yes      |                 |
| `limit`   | integer | No       | (default: `10`) |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/get_subscribers_by_tag \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Count Subscribers By Status

**Endpoint:** `POST /api/v2/tools/count_subscribers_by_status`

Get subscriber count by status. Optionally filter by a specific status.

**Parameters:**

| Parameter | Type                                                                    | Required | Description |
| --------- | ----------------------------------------------------------------------- | -------- | ----------- |
| `status`  | "SUBSCRIBED" \| "UNSUBSCRIBED" \| "BOUNCED" \| "BANNED" \| "COMPLAINED" | No       |             |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/count_subscribers_by_status \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

## Settings Tools

### Get Writing Style

**Endpoint:** `POST /api/v2/tools/get_writing_style`

Get the organization's default writing style for content generation including persona, audience, vocabulary, and rules.

**Parameters:**

No parameters required.

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/get_writing_style \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Update Writing Style

**Endpoint:** `POST /api/v2/tools/update_writing_style`

Modify writing style settings (persona, audience, vocabulary, rules)

**Parameters:**

| Parameter    | Type   | Required | Description |
| ------------ | ------ | -------- | ----------- |
| `name`       | string | No       |             |
| `persona`    | string | No       |             |
| `audience`   | string | No       |             |
| `vocabulary` | string | No       |             |
| `rules`      | string | No       |             |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/update_writing_style \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Get Email Senders

**Endpoint:** `POST /api/v2/tools/get_email_senders`

List all configured email senders (from addresses) for this organization.

**Parameters:**

No parameters required.

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/get_email_senders \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Get Custom Fields

**Endpoint:** `POST /api/v2/tools/get_custom_fields`

List all custom subscriber field definitions for this organization.

**Parameters:**

No parameters required.

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/get_custom_fields \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Create Custom Field

**Endpoint:** `POST /api/v2/tools/create_custom_field`

Create a custom subscriber field definition by name. This only creates the definition (`id` + `name`). It does not set subscriber values or change subscription status.

**Parameters:**

| Parameter | Type   | Required | Description                                                                 |
| --------- | ------ | -------- | --------------------------------------------------------------------------- |
| `name`    | string | Yes      | Field definition name, e.g. `has_double_opt_in` or `has_custom_domains`     |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/create_custom_field \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"has_double_opt_in"}'

curl -X POST https://lumail.io/api/v2/tools/create_custom_field \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"has_custom_domains"}'
```

```bash
lumail tools run create_custom_field \
  --account my-org \
  --json \
  --params '{"name":"has_double_opt_in"}'
```

### Rename Custom Field

**Endpoint:** `POST /api/v2/tools/rename_custom_field`

Rename a custom subscriber field definition by id. Same reserved-name and duplicate checks as create.

**Parameters:**

| Parameter | Type   | Required | Description              |
| --------- | ------ | -------- | ------------------------ |
| `fieldId` | string | Yes      | Field definition id      |
| `name`    | string | Yes      | New field definition name |

### Delete Custom Field

**Endpoint:** `POST /api/v2/tools/delete_custom_field`

Delete a custom subscriber field definition by id. This is a dangerous action and requires confirmation. It removes stored values for that field and does not resubscribe contacts.

**Parameters:**

| Parameter | Type   | Required | Description         |
| --------- | ------ | -------- | ------------------- |
| `fieldId` | string | Yes      | Field definition id |

### Get Email Snippets

**Endpoint:** `POST /api/v2/tools/get_email_snippets`

List email snippets (reusable content blocks) for this organization. Use get_email_snippet to read a snippet's content.

**Parameters:**

| Parameter         | Type    | Required | Description                                |
| ----------------- | ------- | -------- | ------------------------------------------ |
| `includeArchived` | boolean | No       | Include archived snippets (default: false) |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/get_email_snippets \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Get Email Snippet

**Endpoint:** `POST /api/v2/tools/get_email_snippet`

Get a single email snippet by id (snp\_...) including its full TipTap JSON content. Use this before update_email_snippet to read the current content.

**Parameters:**

| Parameter | Type   | Required | Description               |
| --------- | ------ | -------- | ------------------------- |
| `id`      | string | Yes      | Snippet id, e.g. snp\_... |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/get_email_snippet \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Create Email Snippet

**Endpoint:** `POST /api/v2/tools/create_email_snippet`

Create a new email snippet (reusable content block). Content is TipTap JSON with the same node types as campaigns: &#123; type: "doc", content: [...] &#125;. Snippets can then be referenced inside campaign content.

**Parameters:**

| Parameter     | Type    | Required | Description                                                                                      |
| ------------- | ------- | -------- | ------------------------------------------------------------------------------------------------ |
| `name`        | string  | Yes      | Snippet name                                                                                     |
| `description` | string  | No       | Optional description                                                                             |
| `content`     | unknown | No       | TipTap JSON content: &#123; type: "doc", content: [...] &#125;. Omit to create an empty snippet. |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/create_email_snippet \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Update Email Snippet

**Endpoint:** `POST /api/v2/tools/update_email_snippet`

Update an email snippet by id (snp\_...): name, description, and/or its TipTap JSON content (&#123; type: "doc", content: [...] &#125;, same node types as campaigns). Content updates apply everywhere the snippet is used. Read the current content with get_email_snippet first when editing.

**Parameters:**

| Parameter     | Type    | Required | Description                                                                                              |
| ------------- | ------- | -------- | -------------------------------------------------------------------------------------------------------- |
| `id`          | string  | Yes      | Snippet id, e.g. snp\_...                                                                                |
| `name`        | string  | No       | New snippet name                                                                                         |
| `description` | string  | No       | New description                                                                                          |
| `content`     | unknown | No       | New TipTap JSON content: &#123; type: "doc", content: [...] &#125;. Replaces the entire snippet content. |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/update_email_snippet \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Archive Email Snippet

**Endpoint:** `POST /api/v2/tools/archive_email_snippet`

Archive an email snippet by id (snp\_...). Archived snippets are hidden from the snippet list but existing campaigns referencing them keep working. Restore with unarchive_email_snippet.

**Parameters:**

| Parameter | Type   | Required | Description               |
| --------- | ------ | -------- | ------------------------- |
| `id`      | string | Yes      | Snippet id, e.g. snp\_... |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/archive_email_snippet \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Unarchive Email Snippet

**Endpoint:** `POST /api/v2/tools/unarchive_email_snippet`

Restore an archived email snippet by id (snp\_...).

**Parameters:**

| Parameter | Type   | Required | Description               |
| --------- | ------ | -------- | ------------------------- |
| `id`      | string | Yes      | Snippet id, e.g. snp\_... |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/unarchive_email_snippet \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Update Organization Settings

**Endpoint:** `POST /api/v2/tools/update_organization_settings`

Update organization name and timezone

**Parameters:**

| Parameter  | Type   | Required | Description             |
| ---------- | ------ | -------- | ----------------------- |
| `name`     | string | No       | Organization name       |
| `timezone` | string | No       | Timezone in IANA format |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/update_organization_settings \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

## Skill Tools

### Get Available Variables

**Endpoint:** `POST /api/v2/tools/get_available_variables`

Get all available variables for email content (built-in, organization, custom fields, snippets)

**Parameters:**

No parameters required.

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/get_available_variables \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Get Skill

**Endpoint:** `POST /api/v2/tools/get_skill`

Get task-specific documentation. Call it BEFORE writing content, filters, or a workflow graph - never guess a format from memory. Types: 'campaign' (TipTap content + edit_campaign operations), 'filters' (subscriber/recipient filter shapes), 'workflow' (draft graph), 'sending' (domain, DNS and sender setup), 'images', 'snippets', 'templates', 'variables', 'copywriter', 'onboarding', 'docs' (full product docs catalog, or one page with `page`).

**Parameters:**

| Parameter | Type                                                                                                                                       | Required | Description            |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -------- | ---------------------- |
| `type`    | "campaign" \| "snippets" \| "templates" \| "variables" \| "copywriter" \| "filters" \| "images" \| "sending" \| "workflow" \| "onboarding" \| "docs" | Yes      | Skill type to retrieve |
| `page`    | string                                                                                                                                     | No       | With `type: "docs"`, the page slug (example: `workflows/workflow-manual-enrollment`) |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/get_skill \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

## Email Tools

### Send Email

**Endpoint:** `POST /api/v2/tools/send_email`

Send a transactional email to a subscriber. The 'from' address must use a verified domain.

**Parameters:**

| Parameter        | Type    | Required | Description |
| ---------------- | ------- | -------- | ----------- |
| `from`           | string  | Yes      | Verified domain mailbox |
| `to`             | string  | Yes      | One address, or a one-item array |
| `subject`        | string  | Yes      |             |
| `html`           | string  | No       | Exactly one of `html` or `tiptap` |
| `tiptap`         | unknown | No       | Exactly one of `html` or `tiptap` |
| `text`           | string  | No       | Generated from HTML when omitted. `""` skips generation |
| `reply_to`       | string  | No       | Single address (string or one-item array) |
| `replyTo`        | string  | No       | Alias of `reply_to` |
| `cc`             | array   | No       |             |
| `headers`        | object  | No       |             |
| `tags`           | array   | No       | `{ name, value }` |
| `preview`        | string  | No       |             |
| `idempotencyKey` | string  | No       |             |

`content`, `contentType`, and `markdown` are rejected.

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/send_email \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

## Domain Tools

### List Domains

**Endpoint:** `POST /api/v2/tools/list_domains`

List the organization's email sending domains with their verification status. Call get_skill(&#123; type: 'sending' &#125;) for the full domain and sender setup flow.

**Parameters:**

No parameters required.

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/list_domains \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Create Domain

**Endpoint:** `POST /api/v2/tools/create_domain`

Register a new email sending domain and return the DNS records the user must add. Call get_skill(&#123; type: 'sending' &#125;) first for the full setup flow.

**Parameters:**

| Parameter | Type   | Required | Description                                                                                                                   |
| --------- | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `domain`  | string | Yes      | The domain to register, e.g. mail.example.com                                                                                 |
| `region`  | string | No       | SES sending region: `EU_CENTRAL_1` (Frankfurt, default), `AP_SOUTHEAST_2` (Sydney) or `US_WEST_1` (N. California). Cannot be changed later. |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/create_domain \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Get Domain Dns Records

**Endpoint:** `POST /api/v2/tools/get_domain_dns_records`

Get a domain's DNS records with their per-record verification status, to show the user what to configure and what is still pending.

**Parameters:**

| Parameter  | Type   | Required | Description       |
| ---------- | ------ | -------- | ----------------- |
| `domainId` | string | Yes      | The MailDomain id |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/get_domain_dns_records \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Verify Domain

**Endpoint:** `POST /api/v2/tools/verify_domain`

Re-check a domain's verification, DKIM and MAIL FROM state and return the updated per-record DNS statuses. Call get_skill(&#123; type: 'sending' &#125;) for what to do with the result.

**Parameters:**

| Parameter  | Type   | Required | Description       |
| ---------- | ------ | -------- | ----------------- |
| `domainId` | string | Yes      | The MailDomain id |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/verify_domain \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### List Web Domains

**Endpoint:** `POST /api/v2/tools/list_web_domains`

List the organization's web domains (capture pages and click-tracking hosts) with their verification status.

### Get Web Domain

**Endpoint:** `POST /api/v2/tools/get_web_domain`

Retrieve one web domain by id (`wdom_...`) or hostname, including the CNAME the user must add.

**Parameters:**

| Parameter   | Type   | Required | Description                          |
| ----------- | ------ | -------- | ------------------------------------ |
| `domainId`  | string | Yes      | The WebDomain id (`wdom_...`) or hostname |

### Verify Web Domain

**Endpoint:** `POST /api/v2/tools/verify_web_domain`

Re-check a web domain's Cloudflare custom-hostname verification — the same action as **Check Verification Status** on `/domains/web/:id`. Sending domains stay on `verify_domain`.

**Parameters:**

| Parameter   | Type   | Required | Description                          |
| ----------- | ------ | -------- | ------------------------------------ |
| `domainId`  | string | Yes      | The WebDomain id (`wdom_...`) or hostname |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/verify_web_domain \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"domainId":"wdom_..."}'
```

### Create Email Sender

**Endpoint:** `POST /api/v2/tools/create_email_sender`

Create an email sender (from-address) as localPart@domain on an already VERIFIED domain. Call get_skill(&#123; type: 'sending' &#125;) for the prerequisites.

**Parameters:**

| Parameter     | Type    | Required | Description                                              |
| ------------- | ------- | -------- | -------------------------------------------------------- |
| `displayName` | string  | Yes      | The display name shown in the From header                |
| `localPart`   | string  | Yes      | The part before the @, e.g. 'hello' in hello@example.com |
| `domainId`    | string  | Yes      | The verified MailDomain id                               |
| `replyTo`     | unknown | No       | Optional reply-to email address                          |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/create_email_sender \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

## Deliverability Tools

### Get Deliverability Health

**Endpoint:** `POST /api/v2/tools/get_deliverability_health`

Assess the organization's deliverability health (sender reputation). Omit sinceDays for the all-time trust score; pass it for a windowed sent/delivered/bounced/complained report with verdicts.

**Parameters:**

| Parameter   | Type    | Required | Description |
| ----------- | ------- | -------- | ----------- |
| `sinceDays` | integer | No       |             |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/get_deliverability_health \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### List Suppressed Subscribers

**Endpoint:** `POST /api/v2/tools/list_suppressed_subscribers`

List suppressed subscribers (BOUNCED and/or COMPLAINED), most recently suppressed first, with pagination. Each entry includes the most recent bounce/complaint event details (bounceCategory, reason, bounceType, provider, complaintFeedbackType).

**Parameters:**

| Parameter  | Type                      | Required | Description     |
| ---------- | ------------------------- | -------- | --------------- |
| `status`   | "BOUNCED" \| "COMPLAINED" | No       |                 |
| `page`     | integer                   | No       | (default: `1`)  |
| `pageSize` | integer                   | No       | (default: `50`) |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/list_suppressed_subscribers \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Reactivate Subscriber

**Endpoint:** `POST /api/v2/tools/reactivate_subscriber`

Reactivate a suppressed subscriber (BOUNCED, COMPLAINED, or UNSUBSCRIBED) by setting their status back to SUBSCRIBED. Refuses hard bounces (bounceCategory NO_SUCH_USER or INTERNAL / SES suppression list) unless 'force' is true.

**Parameters:**

| Parameter      | Type    | Required | Description |
| -------------- | ------- | -------- | ----------- |
| `subscriberId` | string  | Yes      |             |
| `force`        | boolean | No       |             |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/reactivate_subscriber \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

## Image Tools

### Upload Image

**Endpoint:** `POST /api/v2/tools/upload_image`

Import an image from a public http/https URL into your organization media library. Fetches the image, validates it is an image under 2MB, uploads it to storage, and returns the hosted image with a URL that can be used directly in campaign image blocks.

**Parameters:**

| Parameter     | Type   | Required | Description                                                                                                                                                      |
| ------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `url`         | string | Yes      | Public http(s) URL of the image to import.                                                                                                                       |
| `name`        | string | No       | Optional display name for the imported image.                                                                                                                    |
| `description` | string | No       | Short description of what the image shows (subject, colors, intended usage). Strongly recommended: this is what makes the image findable later with list_images. |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/upload_image \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### List Images

**Endpoint:** `POST /api/v2/tools/list_images`

Search the organization media library. The query matches both the image name and its description, so you can search by what the image shows (e.g. 'logo', 'hero photo'). Newest first.

**Parameters:**

| Parameter       | Type    | Required | Description                                                                               |
| --------------- | ------- | -------- | ----------------------------------------------------------------------------------------- |
| `query`         | string  | No       | Optional case-insensitive search matched against both the image name and its description. |
| `capturePageId` | string  | No       | Only return images uploaded for this capture page. Omit to search the whole library.      |
| `page`          | integer | No       | (default: `1`)                                                                            |
| `pageSize`      | integer | No       | (default: `50`)                                                                           |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/list_images \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Describe Image

**Endpoint:** `POST /api/v2/tools/describe_image`

Set or update the description of an image already in the media library. Use it right after a user uploads an image so it becomes findable later with list_images. Accepts either the image id or its URL.

**Parameters:**

| Parameter     | Type   | Required | Description                                                                            |
| ------------- | ------ | -------- | -------------------------------------------------------------------------------------- |
| `imageId`     | string | No       | Id of the image. Provide either imageId or url.                                        |
| `url`         | string | No       | URL of the image. Provide either imageId or url.                                       |
| `description` | string | Yes      | What the image shows: subject, dominant colors, and how it should be used on the page. |
| `name`        | string | No       | Optional human-readable name replacing the raw filename.                               |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/describe_image \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

## Import and export Tools

### Import Subscribers

**Endpoint:** `POST /api/v2/tools/import_subscribers`

Bulk import subscribers (up to 500 per call). Existing subscribers are updated, new ones created, and workflows are NOT triggered by default. Returns imported/updated/failed counts with per-row errors.

**Parameters:**

| Parameter          | Type    | Required | Description                                                                                    |
| ------------------ | ------- | -------- | ---------------------------------------------------------------------------------------------- |
| `subscribers`      | array   | Yes      |                                                                                                |
| `resubscribe`      | boolean | No       | Re-subscribe previously unsubscribed contacts. (default: `false`)                              |
| `triggerWorkflows` | boolean | No       | Trigger automation workflows on import. Defaults to false for bulk imports. (default: `false`) |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/import_subscribers \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### Export Subscribers

**Endpoint:** `POST /api/v2/tools/export_subscribers`

Export subscribers from your organization. Optionally filter by status and tag name. Returns JSON objects or a CSV string with email, name, phone, status, tags, custom fields, and creation date. Respects a configurable limit (default 1000, max 10000).

**Parameters:**

| Parameter | Type                                                                                              | Required | Description                            |
| --------- | ------------------------------------------------------------------------------------------------- | -------- | -------------------------------------- |
| `status`  | "PENDING_CONFIRMATION" \| "SUBSCRIBED" \| "UNSUBSCRIBED" \| "BOUNCED" \| "BANNED" \| "COMPLAINED" \| "TRANSACTIONAL" | No       |                                        |
| `tag`     | string                                                                                            | No       | Filter by tag name (case-insensitive). |
| `format`  | "json" \| "csv"                                                                                   | No       | (default: `"json"`)                    |
| `limit`   | integer                                                                                           | No       | (default: `1000`)                      |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/export_subscribers \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

For a full filtered audience, queue `create_subscriber_export` and poll `get_subscriber_export`.

## Web Tools

### Fetch Web Page

**Endpoint:** `POST /api/v2/tools/fetch_web_page`

Fetch a web page and convert to markdown format

**Parameters:**

| Parameter | Type   | Required | Description                                                                 |
| --------- | ------ | -------- | --------------------------------------------------------------------------- |
| `url`     | string | Yes      | The full URL of the web page to fetch (must start with http:// or https://) |

**Example:**

```bash
curl -X POST https://lumail.io/api/v2/tools/fetch_web_page \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

## Related Documentation

- [API Tokens](/docs/api-reference/api-tokens) - Create and manage API tokens
- [API Limits](/docs/api-reference/api-limits) - Rate limiting information
- [MCP Integration](/docs/api-reference/mcp) - Use tools via Model Context Protocol

---

# Admin Tools API
Source: https://lumail.io/docs/api-reference/admin-tools.md

The Admin Tools API is an **internal, admin-only** REST interface for platform automation. It exposes a registry of tools behind a single endpoint, secured by dedicated admin API keys.

> **Warning:** Admin API keys can **list every organization** and, with `X-Lumail-Organization`, run the public v2/CLI API as that organization. They are reserved for Lumail administrators — this API is not part of the public API and is not available to regular accounts. If you are looking for the customer-facing API, see [Tools API (v2)](/docs/api-reference/v2-tools).

## Quick Start

Both operations live on a single endpoint: `/api/v1/admin/tools`.

**List all available tools:**

```bash
curl https://lumail.io/api/v1/admin/tools \
  -H "Authorization: Bearer luma_YOUR_ADMIN_KEY"
```

**Execute a tool:**

```bash
curl -X POST https://lumail.io/api/v1/admin/tools \
  -H "Authorization: Bearer luma_YOUR_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"tool": "get_org", "input": {"slug": "acme"}}'
```

## Authentication

All requests require an admin API key as a Bearer token:

```
Authorization: Bearer luma_your_admin_key_here
```

Admin keys are managed from **Admin → API Keys** (`/admin/api-keys`) by platform administrators:

- Keys use the `luma_` prefix followed by 64 hex characters.
- The plaintext key is shown **once** at creation — only a sha256 hash is stored.
- Revoking a key from the admin panel cuts off access immediately.
- `Last used` is tracked automatically (background write, throttled to once per minute).

## Listing Tools

`GET /api/v1/admin/tools` is self-describing: it returns every registered tool with its JSON input schema, plus a `usage` block that shows how to execute them.

**Response:**

```json
{
  "success": true,
  "usage": {
    "execute": "POST /api/v1/admin/tools",
    "body": { "tool": "<tool name>", "input": "<object matching inputSchema>" },
    "example": { "tool": "list_orgs", "input": { "search": "acme", "pageSize": 20 } }
  },
  "total": 2,
  "tools": [
    {
      "name": "list_orgs",
      "description": "List and search every organization...",
      "inputSchema": { "type": "object" }
    },
    {
      "name": "get_org",
      "description": "Get an organization's general information...",
      "inputSchema": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "description": "Organization ID" },
          "slug": { "type": "string", "description": "Organization slug" }
        }
      }
    }
  ]
}
```

## Executing Tools

`POST /api/v1/admin/tools` with a JSON body:

| Field   | Type   | Required | Description                                  |
| ------- | ------ | -------- | -------------------------------------------- |
| `tool`  | string | Yes      | Name of the tool to execute (e.g. `get_org`) |
| `input` | object | No       | Input matching the tool's `inputSchema`      |

The input is validated against the tool's schema before execution — invalid input returns a `400` with the exact validation issues.

**Success response:**

```json
{
  "success": true,
  "tool": "get_org",
  "data": {
    /* tool-specific response */
  }
}
```

## Errors

| Status | Meaning                                                      |
| ------ | ------------------------------------------------------------ |
| `400`  | Invalid input — the message lists each failing field and why |
| `401`  | Missing, malformed, or revoked admin API key                 |
| `404`  | Unknown tool — the message lists the available tool names    |
| `429`  | IP rate limit exceeded — check the `Retry-After` header      |

**Example — unknown tool:**

```json
{ "message": "Unknown tool: get_orgs. Available tools: list_orgs, get_org" }
```

**Example — invalid input:**

```json
{ "message": "Invalid input: Provide only one of id or slug" }
```

## Available Tools (8)

All tools are read-only.

| Tool                   | Description                                                                                                         |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `list_orgs`            | List and search organizations with the same filters as Admin → Organizations.                                       |
| `get_org`              | Get an organization's general information (plan, stats, members) and its configured sending domains.                |
| `get_org_details`      | Read one tab of the admin organization page: `overview`, `resources`, `members`, `billing`, `email-settings`, `dedicated-ip`. |
| `get_org_verification` | Why an organization is blocked or banned: the causing transition and AI review, holds, metadata, full status history. |
| `get_review_emails`    | The emails an AI review sampled, with per-email scores, trigger, plain-text body and links.                         |
| `list_emails`          | List emails (50 per page) filtered by organization, search, status, or subscriber.                                  |
| `get_email`            | One email's delivery lifecycle plus its rendered subject, sender, body and links.                                    |
| `get_user`             | A user account: ban state and reason, providers, sessions, and every organization with its review/sending status.   |

Admin keys can also call the public org-scoped API (`/api/v2/...` and the main CLI) by sending `X-Lumail-Organization: <id-or-slug>`. The CLI flag is `--organization`.

---

### List Organizations

**Tool:** `list_orgs`

Same query as the admin organizations home page.

**Parameters:**

| Parameter        | Type    | Description                                              |
| ---------------- | ------- | -------------------------------------------------------- |
| `search`         | string  | Name, slug, tracking hash, or URL                        |
| `plan`           | string  | `free`, `creator`, `pro`, `premium`, `business`          |
| `status`         | string  | `unverified`, `automated`, `verified`, `blocked`         |
| `activity`       | string  | `active` or `all`                                        |
| `clickDomain`    | string  | `enabled`                                                |
| `doubleOptIn`    | string  | `enabled` or `disabled`                                  |
| `limits`         | string  | `approaching`                                            |
| `sort`           | string  | `asc` or `desc` (default `desc`)                         |
| `sortBy`         | string  | `createdAt`, `name`, `plan`, `members`, `emails`         |
| `page`           | number  | Default `1`                                              |
| `pageSize`       | number  | Default `10`, max `50`                                   |

```bash
curl -X POST https://lumail.io/api/v1/admin/tools \
  -H "Authorization: Bearer luma_YOUR_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"tool": "list_orgs", "input": {"search": "acme", "plan": "pro", "pageSize": 20}}'
```

---

### Get Organization

**Tool:** `get_org`

Get an organization's general information (plan, stats, members) and its configured sending domains with DNS verification status. Provide either `id` or `slug` — never both.

**Parameters:**

| Parameter | Type   | Required | Description       |
| --------- | ------ | -------- | ----------------- |
| `id`      | string | No\*     | Organization ID   |
| `slug`    | string | No\*     | Organization slug |

\* Exactly one of `id` or `slug` is required.

**Example:**

```bash
curl -X POST https://lumail.io/api/v1/admin/tools \
  -H "Authorization: Bearer luma_YOUR_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"tool": "get_org", "input": {"slug": "acme"}}'
```

**Response:**

```json
{
  "success": true,
  "tool": "get_org",
  "data": {
    "organization": {
      "id": "aBcD1234...",
      "name": "Acme",
      "slug": "acme",
      "logo": "https://...",
      "email": "team@acme.com",
      "timezone": "Europe/Paris",
      "verified": true,
      "createdAt": "2026-01-01T00:00:00.000Z"
    },
    "subscription": {
      "plan": "PREMIUM",
      "status": "active",
      "periodStart": "2026-07-01T00:00:00.000Z",
      "periodEnd": "2026-08-01T00:00:00.000Z",
      "cancelAtPeriodEnd": false,
      "customLimits": null
    },
    "stats": {
      "members": 3,
      "subscribers": 1200,
      "campaigns": 42
    },
    "domains": [
      {
        "id": "dom_...",
        "domain": "mail.acme.com",
        "status": "VERIFIED",
        "region": "EU_WEST_1",
        "sesRecords": [
          {
            "name": "_amazonses.mail.acme.com",
            "type": "TXT",
            "value": "...",
            "status": "verified"
          }
        ],
        "fallbackPriority": null,
        "verificationRequestedAt": null,
        "createdAt": "2026-02-01T00:00:00.000Z"
      }
    ]
  }
}
```

- `subscription` is `null` when the organization has no active, trialing, or past-due subscription (free plan).
- `domains` includes the full SES DNS records with per-record verification status — useful to debug deliverability without impersonating the account.

### Explaining a Block or Ban

The typical agent flow to answer "why is this account banned?":

1. `get_user` with `{ "email": "..." }` → account ban (`banned`, `banReason`) and each organization's `reviewStatus` / `sendingStatus`.
2. `get_org_verification` with `{ "slug": "..." }` → `blockReason` is the transition that moved the organization into `BLOCKED`/`BANNED` (actor, `reasonCode`, `adminNote`, `customerExplanation`) with its AI review (`verdict`, `explanation`, `maxScores`, per-email `scores`).
3. `get_review_emails` with `{ "slug": "...", "onlyFlagged": true }` → the flagged emails' subject, plain-text body and links, to quote concrete examples to the customer.

```bash
curl -X POST https://lumail.io/api/v1/admin/tools \
  -H "Authorization: Bearer luma_YOUR_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"tool": "get_review_emails", "input": {"slug": "acme", "onlyFlagged": true, "maxChars": 3000}}'
```

**`get_org_verification`** — `id` or `slug`. Returns `status`, `blockReason` (or `null` when not blocked), `openHolds`, `verificationMetadata` (`refuseReason`, `adminMessage`, `defensiveFlag`), `reputation`, `members` (with account ban fields), `transitions` (50 latest), `holds`, `reviews` (20 latest) and `milestoneClaims`.

**`get_review_emails`** — `id` or `slug`, plus:

| Parameter     | Type    | Description                                                                                                  |
| ------------- | ------- | ------------------------------------------------------------------------------------------------------------ |
| `reviewId`    | string  | Review from `get_org_verification`. Default: the review behind the current block, else the latest non-LEGIT one |
| `onlyFlagged` | boolean | Only emails the AI flagged. Default `false`                                                                  |
| `maxChars`    | number  | Max body characters per email, 200 to 50,000. Default `4000`                                                 |

**`get_org_details`** — `id` or `slug`, plus `section` (default `overview`).

**`list_emails`** — optional `id` or `slug`, `page`, `query` (subject, recipient, or email ID), `status` (`ALL`, `SENT`, `DELIVERED`, `BOUNCED`, `COMPLAINED`, `CANCELLED`, `FAILED`, …), `subscriberId`.

**`get_email`** — `emailId`, optional `organizationId`, `includeContent` (default `true`), `format` (`text`, `html`, `both`; default `text`), `maxChars` (default `20000`).

**`get_user`** — `id` or `email`.

## Adding New Tools

Tools live in an extensible registry in `src/lib/admin-tools/`. See `src/lib/admin-tools/README.md` in the repository for the step-by-step guide — new tools automatically appear in the `GET` listing, the execution endpoint, and the `/admin/api-keys` usage panel.

---

# Rate Limits
Source: https://lumail.io/docs/api-reference/api-limits.md

Lumail enforces rate limits to ensure fair usage and protect service quality for all users. This page explains how rate limiting works and how to handle rate-limited requests.

## Rate Limits by Plan

API requests are rate-limited per organization based on your subscription plan:

| Plan       | Requests per Minute |
| ---------- | ------------------- |
| Free       | 100                 |
| Premium    | 700                 |
| Enterprise | 2,000               |

Additionally, all requests are subject to an IP-based rate limit of **500 requests per second** to prevent abuse.

## Rate Limit Headers

Authenticated REST API responses include the current organization quota. Authentication failures still advertise the minimum API policy so clients can plan before presenting a token.

| Header                | Description                                      |
| --------------------- | ------------------------------------------------ |
| `RateLimit-Policy`    | Named quota and 60-second window                 |
| `RateLimit`           | Remaining requests and seconds until reset       |
| `RateLimit-Limit`     | Maximum requests allowed per minute              |
| `RateLimit-Remaining` | Remaining requests in the current window         |
| `RateLimit-Reset`     | Seconds until the current window resets          |
| `X-RateLimit-*`       | Legacy compatibility values for existing clients |

**Example headers:**

```http
RateLimit-Policy: "lumail-api";q=100;w=60
RateLimit: "lumail-api";r=95;t=42
RateLimit-Limit: 100
RateLimit-Remaining: 95
RateLimit-Reset: 42
```

## Rate Limit Exceeded Response

When you exceed the rate limit, the API returns a `429 Too Many Requests` response:

```json
{
  "message": "Too many requests",
  "docs": "https://lumail.io/docs/api-reference/api-limits"
}
```

**Additional headers on 429 responses:**

| Header        | Description                     |
| ------------- | ------------------------------- |
| `Retry-After` | Seconds to wait before retrying |

## Handling Rate Limits

### Best Practices

1. **Monitor headers** - Track `RateLimit-Remaining` to know when you're approaching the limit
2. **Implement backoff** - When rate limited, wait for the `Retry-After` duration before retrying
3. **Batch requests** - Combine multiple operations into fewer API calls where possible
4. **Cache responses** - Store frequently accessed data locally to reduce API calls

### Exponential Backoff Example

```javascript
async function fetchWithRetry(url, options, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    const response = await fetch(url, options);

    if (response.ok) {
      return response;
    }

    if (response.status === 429) {
      // Get retry delay from header or calculate exponentially
      const retryAfter = response.headers.get("Retry-After");
      const waitTime = retryAfter
        ? parseInt(retryAfter, 10) * 1000
        : Math.pow(2, i) * 1000;

      console.log(`Rate limited. Waiting ${waitTime}ms before retry...`);
      await new Promise((resolve) => setTimeout(resolve, waitTime));
      continue;
    }

    throw new Error(`HTTP ${response.status}: ${await response.text()}`);
  }

  throw new Error("Max retries exceeded");
}
```

### Checking Rate Limit Status

Before making requests, you can check your remaining quota:

```javascript
const response = await fetch("https://lumail.io/api/v1/tags", {
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
  },
});

const limit = response.headers.get("RateLimit-Limit");
const remaining = response.headers.get("RateLimit-Remaining");
const resetAfter = response.headers.get("RateLimit-Reset");

console.log(`${remaining}/${limit} requests remaining`);
console.log(`Resets in ${resetAfter} seconds`);
```

## Increasing Your Rate Limit

If you need higher rate limits:

1. **Upgrade your plan** - Premium and Enterprise plans include higher limits
2. **Contact support** - For custom enterprise needs, we can discuss tailored solutions

## Common Scenarios

### Bulk Operations

For bulk imports or updates, consider:

- Using the `POST /api/v1/subscribers` endpoint which handles upserts
- Spreading requests over time instead of bursting
- Processing in batches with delays between batches

```javascript
async function bulkImport(subscribers, batchSize = 50, delayMs = 1000) {
  for (let i = 0; i < subscribers.length; i += batchSize) {
    const batch = subscribers.slice(i, i + batchSize);

    // Process batch in parallel
    await Promise.all(
      batch.map((sub) =>
        fetch("https://lumail.io/api/v1/subscribers", {
          method: "POST",
          headers: {
            Authorization: "Bearer YOUR_API_TOKEN",
            "Content-Type": "application/json",
          },
          body: JSON.stringify(sub),
        }),
      ),
    );

    // Wait between batches to stay under rate limit
    if (i + batchSize < subscribers.length) {
      await new Promise((resolve) => setTimeout(resolve, delayMs));
    }
  }
}
```

### Webhooks

When receiving webhooks and calling the API:

- Process webhook events asynchronously
- Implement a queue to control request rate
- Use `triggerWorkflows: false` when updating subscribers to avoid cascading API calls

## Recipient rate guards are a different 429

`Too many requests` is this page: organization HTTP rpm.

A transactional send can also return **429** with `code: RECIPIENT_RATE_LIMITED` and `message: This recipient is temporarily rate limited`. That mailbox already hit **5 sends / 10 minutes** or **20 / 24 hours**, or is on a bounce cooldown. Honor `Retry-After`. Do not raise your plan rpm for that error.

See [Recipient rate guards](/docs/deliverability/recipient-rate-guards).

## Related Documentation

- [API Tokens](/docs/api-reference/api-tokens) - Authentication setup
- [Create Subscriber](/docs/api-reference/api-subscribers-post) - Bulk import best practices
- [Workflows](/docs/workflows) - Automate without API calls
- [Recipient rate guards](/docs/deliverability/recipient-rate-guards) - Per-mailbox send caps, not HTTP rpm

---

# Versioning and Deprecation Policy
Source: https://lumail.io/docs/api-reference/versioning.md

Lumail versions the REST API with a major version in the URL path: `/api/v1` and `/api/v2`. The same `lum_` Bearer token works on both.

## Current versions

| Version  | Status | Use it for                                                                 |
| -------- | ------ | -------------------------------------------------------------------------- |
| `/api/v2` | Active | Every new integration. See [API v2](/docs/api-reference/v2).               |
| `/api/v1` | Frozen | Existing integrations only. No new features, no scheduled sunset date yet. |

To move an existing integration, follow [Migration to V2](/docs/api-reference/v2/migration).

## What can change inside a version

Inside a major version, Lumail only ships additive changes:

- new endpoints
- new optional request fields
- new response fields
- new error codes documented in the [OpenAPI specification](https://lumail.io/openapi.json)

Clients should ignore unknown response fields. Removing or renaming a field, changing a type, or making an optional field required only happens in a new major version.

## How deprecation and sunset are announced

Before a version or an endpoint is retired, Lumail:

1. Announces the deprecation and the retirement date in the [changelog](/changelog) and on this page.
2. Returns a `Deprecation` header ([RFC 9745](https://www.rfc-editor.org/rfc/rfc9745)) on every response from the deprecated surface, with a `Link` to this policy (`rel="deprecation"`).
3. Adds a `Sunset` header ([RFC 8594](https://www.rfc-editor.org/rfc/rfc8594)) with the retirement date once it is scheduled.

Monitor these headers in your client to detect deprecations automatically. `/api/v1` does not return them today because it has no retirement date.

## Related

- [API limits](/docs/api-reference/api-limits): rate-limit headers and `Retry-After`
- [MCP server](/docs/api-reference/mcp): the MCP tools follow the same API surface

---

# SMTP Endpoint
Source: https://lumail.io/docs/api-reference/smtp.md

Lumail exposes an SMTP submission endpoint for tools that cannot call the HTTP API directly. It is intended for low-volume transactional email such as auth codes, magic links, password resets, and operational messages.

If you are setting this up for an organization, start with the [SMTP transactional email tutorial](/docs/tutorials/smtp-transactional-emails). It covers verified domains, API tokens, provider settings, and test sends.

For systems that support HTTP hooks, prefer [POST /api/v1/emails](/docs/api-reference/api-emails-send). The HTTP API has a clearer request/response model and is easier to debug. The SMTP endpoint exists for compatibility with SMTP-only providers.

Under the hood, the SMTP endpoint is a thin adapter: it authenticates with your Lumail API token, parses the SMTP/MIME message, then forwards the request to `POST /api/v1/emails`. The HTTP API remains responsible for token validation, verified sending-domain checks, quotas, and priority queueing.

> **Test-mode recipients.** Fixture addresses such as `user@example.com` never send real email. See [Test-mode recipients](/docs/api-reference/test-mode).
>
> **Recipient rate guards.** After the HTTP forward, the same real mailbox is capped at 5 sends / 10 minutes and 20 / 24 hours. See [Recipient rate guards](/docs/deliverability/recipient-rate-guards).

## Connection Settings

| Setting  | Value                                              |
| -------- | -------------------------------------------------- |
| Host     | `smtp.lumail.io`                                   |
| Port     | `587`                                              |
| Security | STARTTLS                                           |
| Username | Any non-empty value, for example your org slug     |
| Password | A Lumail API token from **Settings -> API Tokens** |

The username is used as a log label. The password must be a Lumail API token. The `From` address must use a verified Lumail sending domain in the same organization as the API token.

## Priority

SMTP messages are treated as transactional by default. They are queued on Lumail's transactional priority QStash lane, separate from campaign and newsletter batches.

The default queue lane capacity is 40 emails per second for priority messages and 40 emails per second for bulk campaign messages. This separation prevents a large newsletter send from blocking OTP or magic-link delivery.

## Message Rules

| Rule        | Default                                                            |
| ----------- | ------------------------------------------------------------------ |
| Recipients  | 1 recipient per SMTP message                                       |
| Attachments | Rejected                                                           |
| Size limit  | 1 MB                                                               |
| Tracking    | Link and open tracking enabled (same default as the transactional API) |
| Content     | HTML is preserved when present; otherwise text is sent as Markdown |

## Supabase Auth

Supabase Auth can send email through a custom SMTP provider. Configure it with:

```text
Host: smtp.lumail.io
Port: 587
Security: STARTTLS
Username: your-org-slug
Password: lum_your_api_token
Sender email: auth@your-verified-domain.com
```

If you can use Supabase's Send Email hook instead, prefer the hook and call `POST /api/v1/emails` from your endpoint. It gives you structured errors and avoids SMTP parsing.

## SMTP Responses

| Response | Meaning                                     |
| -------- | ------------------------------------------- |
| `250`    | Message accepted and queued by Lumail       |
| `535`    | Invalid Lumail API token                    |
| `530`    | Authentication required                     |
| `550`    | Invalid sender, recipient, or message       |
| `552`    | Message too large or attachment rejected    |
| `452`    | Temporary queue, quota, or plan-limit issue |
| `451`    | Lumail API temporarily unavailable          |

Lumail returns `250` only after the transactional priority queue accepts the message.

## Related Documentation

- [SMTP Transactional Email Tutorial](/docs/tutorials/smtp-transactional-emails) - Configure SMTP for an organization
- [Create an API Token](/docs/tutorials/create-api-token) - Generate the SMTP password
- [Email Domains](/docs/domains/email-domains) - Verify the `From` domain
- [Recipient rate guards](/docs/deliverability/recipient-rate-guards) - Per-mailbox send caps

---

# Test-mode recipients
Source: https://lumail.io/docs/api-reference/test-mode.md

Lumail accepts sends to fixture recipients and returns a success id without sending real email. Campaigns, workflows, transactional API, SMTP, and test emails share this gate. Real mailboxes still go through the email service.

Use these addresses to exercise the full send path — including production e2e — without bouncing or collecting complaints on the shared sending pool. Product walkthrough: [Test Workflow](/docs/workflows/test-workflow).

## Fixture addresses

| Pattern | Example |
| ------- | ------- |
| Local-part starts with `playwright-test-` | `playwright-test-foo@yahoo.com` |
| Domain is `example.com`, `example.net`, or `example.org`, including subdomains | `user@example.com`, `user@mail.example.org` |
| Domain is `test.com`, `test.net`, `test.org`, or `test.dev`, including subdomains | `user@test.com`, `user@qa.test.dev` |
| TLD is `.test`, `.example`, `.invalid`, or `.localhost` | `user@signup.test`, `user@nowhere.invalid` |

Display names are stripped first (`Name <user@example.com>` is still test-mode).

## Not test-mode

- Real mailboxes such as `user@gmail.com` still send real email when sending is enabled.
- `example.edu` is not reserved for fixtures and still sends real email.
- Lookalikes such as `contest.com` or `mytest.com` still send real email.

Real mailboxes still go through [recipient rate guards](/docs/deliverability/recipient-rate-guards) (5 sends / 10 minutes, 20 / 24 hours). Fixture addresses do not.

## Related

- [Test Workflow](/docs/workflows/test-workflow)
- [Send Transactional Email](/docs/api-reference/api-emails-send)
- [Send Email (v2)](/docs/api-reference/v2/emails-send)
- [SMTP](/docs/api-reference/smtp)
- [Recipient rate guards](/docs/deliverability/recipient-rate-guards)

---

# Send Transactional Email
Source: https://lumail.io/docs/api-reference/api-emails-send.md

`https://lumail.io/api/v1/emails`

Send individual transactional emails directly to subscribers without creating a campaign. Perfect for welcome emails, password resets, order confirmations, and other automated communications.

> **Test-mode recipients.** Fixture addresses such as `user@example.com` never send real email. See [Test-mode recipients](/docs/api-reference/test-mode).
>
> **Recipient rate guards.** The same real mailbox is capped at 5 sends / 10 minutes and 20 / 24 hours. See [Recipient rate guards](/docs/deliverability/recipient-rate-guards).

> Sending to an address that is not already present creates a `TRANSACTIONAL` contact unless **Settings → Configuration → Add transactional recipients to the marketing list** is on. This endpoint is not a supported custom double-opt-in state machine; read [GDPR in Lumail](/docs/legal/gdpr-in-lumail#native-and-custom-double-opt-in) before using it for confirmation emails.

## Delivery Model and Priority

Lumail is API-first. For systems that support HTTP email hooks, call this endpoint from your hook handler. For tools that only support SMTP, use the [Lumail SMTP endpoint](/docs/api-reference/smtp), which accepts SMTP messages and queues them through the same transactional priority lane as this API.

For Supabase Auth email OTPs or magic links, use Supabase's **Send Email** hook when available. If the source can only use SMTP, follow the [SMTP transactional email tutorial](/docs/tutorials/smtp-transactional-emails) and configure `smtp.lumail.io` as the custom SMTP provider.

This endpoint, the SDK `emails.send()` method, the CLI `emails send` command, and the MCP/AI `send_email` tool use a dedicated QStash priority lane. Campaign and newsletter batches use a separate bulk lane, so a large newsletter send does not place OTP, password reset, or manually triggered transactional emails behind the campaign backlog.

The send is still asynchronous: a successful response means Lumail accepted and queued the email. End-to-end delivery depends on downstream provider and recipient mailbox behavior, but priority emails are dispatched through the priority lane as soon as priority-lane capacity is available.

## Request Body

| Field         | Type   | Required | Description                                                                      |
| ------------- | ------ | -------- | -------------------------------------------------------------------------------- |
| `to`          | string | Yes      | Email address to send the email to (subscriber will be created if doesn't exist) |
| `subject`     | string | Yes      | Email subject line                                                               |
| `content`     | string | Yes      | Email content (format depends on contentType)                                    |
| `contentType` | string | No       | Content format: `MARKDOWN` (default), `HTML`, or `TIPTAP`                        |
| `preview`     | string | No       | Email preview text shown in email clients                                        |
| `replyTo`     | string | No       | Reply-to email address (must be valid email format)                              |
| `from`        | string | Yes      | Sender email address (domain must be verified)                                   |
| `tracking`    | object | No       | Tracking options (see below)                                                     |

### Tracking Options

| Field            | Type    | Default | Description                                                       |
| ---------------- | ------- | ------- | ----------------------------------------------------------------- |
| `tracking.links` | boolean | `true` | Wrap links with tracking URLs. Set `false` to keep original hrefs. |
| `tracking.open`  | boolean | `true` | Insert the open-tracking pixel. Set `false` to omit it.            |

Omit `tracking` to keep the default (both on). Set both options to `false` for OTP / magic-link mail if you do not want the extra hop or pixel.

Open and click tracking can process personal data. Pass `tracking.open` and `tracking.links` as `false` when tracking is not lawful or not wanted.

### Subscriber creation and double opt-in

When `to` does not match an existing subscriber, Lumail creates one before queuing the email:

- By default the subscriber is created as `TRANSACTIONAL` (not marketing-eligible, still receives transactional mail).
- If **Add transactional recipients to the marketing list** is on, the subscriber is created as `SUBSCRIBED`. Double opt-in is ignored.
- Existing contacts keep their current status. Blocked addresses (`BANNED`, `BOUNCED`, `COMPLAINED`) are rejected.

The transactional API does not generate or expose the native Lumail confirmation token. Adding a tag after your own confirmation does not change Lumail's subscription status or populate `confirmedAt` and `confirmationIp`. Use the Subscribers API with Lumail's native double opt-in for the supported confirmation lifecycle, or manage consent and marketing eligibility entirely in your own system.

## Response

- **Success (200 OK)** - Email queued successfully for sending
- **Error (400 Bad Request)** - Invalid request data
- **Error (401 Unauthorized)** - Invalid or missing API token
- **Error (404 Not Found)** - Subscriber not found

## Response Fields

| Field             | Type    | Description                               |
| ----------------- | ------- | ----------------------------------------- |
| `success`         | boolean | Indicates if the operation was successful |
| `message`         | string  | Success message                           |
| `id`              | string  | Email ID (`eml_…`) used after the send is persisted |

## Content Types

Choose the format that best fits your use case:

| Type       | Description                                   | Best For                                 |
| ---------- | --------------------------------------------- | ---------------------------------------- |
| `MARKDOWN` | Plain text with Markdown formatting (default) | Simple emails, quick integration         |
| `HTML`     | Raw HTML content                              | Full design control, existing templates  |
| `TIPTAP`   | Tiptap JSON format (same as Lumail editor)    | Complex layouts, programmatic generation |

See detailed documentation for each format:

- [Send Email in Markdown](/docs/api-reference/api-emails-markdown)
- [Send Email in HTML](/docs/api-reference/api-emails-html)
- [Send Email in Tiptap](/docs/api-reference/api-emails-tiptap)

## Email Content Format

The `content` field uses Markdown format. You can include various elements:

### Basic Text

```markdown
Hello {{name}}, this is a basic email.
```

### Formatted Text

```markdown
This is **bold text** and this is _italic text_.
```

### Headings and Lists

```markdown
# Welcome {{name}}!

## Benefits

- First benefit
- Second benefit
- Third benefit

### Getting Started

1. Step one
2. Step two
3. Step three
```

### Links and Images

```markdown
Visit our [website](https://example.com) for more information.

![Company Logo](https://example.com/logo.png)
```

## Variable Substitution

You can use variables in your email content that will be automatically replaced with subscriber data:

- `{{name}}` - Subscriber's name
- `{{email}}` - Subscriber's email address
- `{{phone}}` - Subscriber's phone number
- `{{unsubscribeUrl}}` - Automatic unsubscribe link
- `{{customField}}` - Any custom field defined for the subscriber

## Domain Verification

The `from` email address domain must be verified in your account. The API will:

1. Extract the domain from the `from` email address
2. Verify the domain is authorized and verified for your organization
3. Reject the request if the domain is not verified

## Email Tracking

Transactional emails automatically include:

- **Open tracking** - Pixel tracking to detect email opens
- **Click tracking** - Link wrapping for click tracking
- **Unsubscribe links** - Automatic unsubscribe functionality
- **Email storage** - All sent emails are stored for analytics

## Rate Limiting

Emails are queued and sent at a controlled rate to ensure deliverability:

- Rate: 40 emails per second per delivery lane
- Parallelism: 40 concurrent sends per delivery lane
- Combined QStash dispatch: up to 80 emails per second when both priority and bulk lanes are saturated
- Priority queue: Transactional and manually triggered emails use the priority lane
- Bulk queue: Campaign, workflow campaign, and newsletter batches use the bulk lane
- Queue: Emails are processed through QStash for reliability

## Usage Examples

### Welcome Email

Perfect for onboarding new users:

```json
{
  "to": "newuser@example.com",
  "subject": "Welcome to {{companyName}}!",
  "content": "# Welcome {{name}}!\n\nThanks for joining us. Get started by exploring your dashboard.\n\n## Next Steps\n\n- Complete your profile\n- Explore our features\n- Join our community\n\nWe're excited to have you on board!",
  "from": "welcome@yourcompany.com",
  "preview": "Welcome to the platform"
}
```

### Order Confirmation

For e-commerce order confirmations:

```json
{
  "to": "customer@example.com",
  "subject": "Order Confirmation #{{orderNumber}}",
  "content": "## Thanks for your order, {{name}}!\n\nYour order #{{orderNumber}} has been confirmed and will be processed shortly.\n\n### Order Details\n\n- Order Number: {{orderNumber}}\n- Total: {{orderTotal}}\n- Shipping Address: {{shippingAddress}}\n\n**Estimated Delivery:** {{deliveryDate}}\n\nIf you have any questions, please don't hesitate to contact our support team.",
  "from": "orders@yourstore.com",
  "replyTo": "support@yourstore.com"
}
```

### Password Reset

For password reset notifications:

```json
{
  "to": "user@example.com",
  "subject": "Password Reset Request",
  "content": "Hi {{name}},\n\nYou requested a password reset for your account. Click the link below to reset your password:\n\n[Reset Password]({{resetUrl}})\n\n**This link will expire in 24 hours.**\n\nIf you didn't request this reset, please ignore this email or contact our support team if you have concerns.\n\nStay secure!",
  "from": "security@yourapp.com"
}
```

## Error Handling

Common error responses:

### Invalid Email Address

```json
{
  "message": "Invalid email format"
}
```

### Unauthorized Domain

```json
{
  "message": "Domain 'example.com' is not authorized or verified for this organization"
}
```

### Invalid Email Format

```json
{
  "message": "Invalid from email address"
}
```

### Validation Error

```json
{
  "message": "Validation failed",
  "errors": [
    {
      "field": "subject",
      "message": "Subject is required"
    }
  ]
}
```

## Related Documentation

- [Variables](/docs/features/variables) - Personalize emails with subscriber data
- [Content Deliverability Checker](/docs/features/content-deliverability) - Optimize emails before sending
- [Email Domains Setup](/docs/domains/email-domains) - Configure verified sending domains
- [API Tokens](/docs/api-reference/api-tokens) - Generate API tokens for authentication
- [Create Subscriber](/docs/api-reference/api-subscribers-post) - Add subscribers to send emails to
- [Recipient rate guards](/docs/deliverability/recipient-rate-guards) - Per-mailbox send caps (5/10 min, 20/24 h)


## API Reference
**Method:** POST
**Endpoint:** /api/v1/emails

### SDK
```ts
import { Lumail } from "lumail";
const lumail = new Lumail({ apiKey: "YOUR_API_TOKEN" });

const { id } = await lumail.emails.send({
  to: "user@example.com",
  from: "hello@yourdomain.com",
  subject: "Welcome to our platform!",
  content: "# Welcome {{name}}!\n\nHello {{name}}, welcome to our platform!",
  preview: "Welcome email preview",
});
console.log(id);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v1/emails \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "user@example.com",
    "subject": "Welcome to our platform!",
    "content": "# Welcome {{name}}!\n\nHello {{name}}, welcome to our platform!",
    "preview": "Welcome email preview",
    "from": "hello@yourdomain.com"
  }'
```

### JavaScript
```javascript
const response = await fetch('https://lumail.io/api/v1/emails', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer YOUR_API_TOKEN',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    to: 'user@example.com',
    subject: 'Welcome to our platform!',
    content: '# Welcome {{name}}!\n\nHello {{name}}, welcome to our platform!',
    preview: 'Welcome email preview',
    from: 'hello@yourdomain.com'
  })
});
const data = await response.json();
```

### Python
```python
import requests

url = "https://lumail.io/api/v1/emails"
headers = {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json"
}
payload = {
    "to": "user@example.com",
    "subject": "Welcome to our platform!",
    "content": "# Welcome {{name}}!\n\nHello {{name}}, welcome to our platform!",
    "preview": "Welcome email preview",
    "from": "hello@yourdomain.com"
}
response = requests.post(url, headers=headers, json=payload)
```

### Success Response
```json
{
  "success": true,
  "message": "Email queued for sending",
  "id": "eml_abc123def456"
}
```

### Error Response
```json
{
  "message": "Subscriber not found"
}
```

---

# Email Verification API
Source: https://lumail.io/docs/api-reference/api-email-verify-post.md

`POST /api/v2/verify` · `lumail.verify()`

Checks syntax, disposable and blocked domains, spam-like or role-based mailbox names, domain typos, trusted global history, and DNS mail routing. Permission: `emails`.

HTTP 200 for both accept and reject. Use `accept` for the policy decision and `issues` for every error or warning. Full field reference and score bands: [Verify](/docs/api-reference/v2/verify).

*Older emails.verify path: [emails.verify](/docs/api-reference/v2/emails-verify).*


## API Reference
**Method:** POST
**Endpoint:** /api/v2/verify

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.verify("user@example.com");
if (error) {
  throw error;
}
if (!data.accept) {
  throw data.issues[0];
}
console.log(data.result, data.score, data.suggestion);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/verify \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com"}'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/verify", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ email: "user@example.com" }),
});
const data = await response.json();
```

### Success Response
```json
{
  "object": "email_verification",
  "id": "vr_abc123",
  "email": "user@example.com",
  "result": "risky",
  "accept": true,
  "status": "completed",
  "score": 80,
  "suggestion": null,
  "issues": [],
  "quota": {
    "limit": 10000,
    "used": 12,
    "remaining": 9988,
    "resets_at": "2026-10-01T00:00:00.000Z"
  },
  "retry_at": null,
  "cache_hit": false
}
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "Email is required",
  "statusCode": 422
}
```

---

# Create Subscriber
Source: https://lumail.io/docs/api-reference/api-subscribers-post.md

`https://lumail.io/api/v1/subscribers`

Add a new subscriber to your email list or update an existing one. Subscribers are identified by their email address.

## Response

- **Success (200 OK)** - Returns the subscriber object.
- **Error (400 Bad Request)** - Generic message if the email is invalid or if the subscriber limit is reached.
- **Error (402 Payment Required)** - Indicates that the subscriber limit is reached.

## Response Fields

| Field                          | Type           | Description                                                                     |
| ------------------------------ | -------------- | ------------------------------------------------------------------------------- |
| `success`                      | boolean        | Indicates if the operation was successful                                       |
| `subscriber`                   | object         | The subscriber object with all its properties                                   |
| `subscriber.id`                | string         | Unique identifier for the subscriber                                            |
| `subscriber.email`             | string         | Email address of the subscriber                                                 |
| `subscriber.name`              | string or null | Name of the subscriber (if provided)                                            |
| `subscriber.phone`             | string or null | Phone number of the subscriber (if provided)                                    |
| `subscriber.unsubscribeReason` | string or null | Reason provided when unsubscribing (if applicable)                              |
| `subscriber.status`            | string         | Current status of the subscriber (e.g., "SUBSCRIBED", "UNSUBSCRIBED", "BANNED") |
| `subscriber.clickCount`        | number         | Total number of clicks from this subscriber                                     |
| `subscriber.openCount`         | number         | Total number of email opens from this subscriber                                |
| `subscriber.revenue`           | number         | Total revenue attributed to this subscriber                                     |
| `subscriber.organizationId`    | string         | ID of the organization this subscriber belongs to                               |
| `subscriber.ipAddress`         | string or null | IP address of the subscriber (captured at signup)                               |
| `subscriber.country`           | string or null | ISO 3166-1 alpha-2 country code (e.g., "US", "FR", "CH")                        |
| `subscriber.createdAt`         | string         | ISO timestamp when the subscriber was created                                   |
| `subscriber.updatedAt`         | string         | ISO timestamp when the subscriber was last updated                              |
| `subscriber.fields`            | array          | Custom field values associated with this subscriber                             |
| `subscriber.fields[].id`       | string         | Unique identifier for the field                                                 |
| `subscriber.fields[].name`     | string         | Name of the field                                                               |
| `subscriber.fields[].value`    | string         | Value of the field                                                              |
| `subscriber.tags`              | array          | Tags associated with this subscriber                                            |
| `subscriber.tags[].id`         | string         | Unique identifier for the tag                                                   |
| `subscriber.tags[].name`       | string         | Name of the tag                                                                 |

## Request Schema

| Field              | Type     | Default  | Description                                                                                                                                                     |
| ------------------ | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `email`            | string   | required | Email address of the subscriber                                                                                                                                 |
| `name`             | string   | optional | Name of the subscriber                                                                                                                                          |
| `phone`            | string   | optional | Phone number of the subscriber                                                                                                                                  |
| `tags`             | string[] | `[]`     | Tag names to assign to the subscriber                                                                                                                           |
| `fields`           | object   | optional | Custom field values (key-value pairs). Note: `email`, `name`, and `phone` are reserved field names and will be ignored if used.                                 |
| `replaceTags`      | boolean  | `false`  | If `true`, replaces all existing tags with the provided ones                                                                                                    |
| `resubscribe`      | boolean  | `true`   | If `true`, resubscribes the user if they were previously unsubscribed. For POST, this defaults to `true` since the user is actively subscribing                 |
| `triggerWorkflows` | boolean  | `true`   | If `false`, skip triggering workflows even if subscriber's tags match workflow triggers. Useful for bulk imports or syncing data without triggering automations |
| `skipDoubleOptIn`  | boolean  | `false`  | If `true`, create the contact as `SUBSCRIBED` even when organization double opt-in is on. No confirmation email or token. Does not write `confirmedAt`. Does not resubscribe `UNSUBSCRIBED` unless `resubscribe` is also true. Use for trusted backends (checkout, server import), not public forms. |
| `ipAddress`        | string   | optional | IP address of the subscriber. Used to auto-detect country if `country` is not provided                                                                          |
| `country`          | string   | optional | ISO 3166-1 alpha-2 country code (e.g., "US", "FR", "CH"). Auto-detected from `ipAddress` if not provided                                                        |

### Reserved Field Names

The following field names are reserved and cannot be used in the `fields` object:

- `email` - Use the dedicated `email` parameter instead
- `name` - Use the dedicated `name` parameter instead
- `phone` - Use the dedicated `phone` parameter instead

If you attempt to use a reserved field name, it will be ignored and a warning will be returned in the response.

## Usage Example

Use this API endpoint to:

- Add new subscribers to your email list from external forms or applications
- Import subscribers from other systems
- Update existing subscriber information
- Add custom fields for personalization
- Assign tags for segmentation and targeting
- Track subscriber information for analytics and reporting

## Related Documentation

- [Tags API](/docs/api-reference/api-tags-get) - Manage subscriber tags
- [Add Tags to Subscriber](/docs/api-reference/api-subscriber-tags-post) - Add tags after creation
- [Verify](/docs/api-reference/v2/verify) - Validate emails before adding
- [Enable Double Opt-In](/docs/tutorials/enable-double-opt-in) - Native confirmation before campaigns
- [Workflows](/docs/workflows) - Trigger automations when subscribers are added
- [Variables](/docs/features/variables) - Use subscriber data in emails


## API Reference
**Method:** POST
**Endpoint:** /api/v1/subscribers

### SDK
```ts
import { Lumail } from "lumail";
const lumail = new Lumail({ apiKey: "YOUR_API_TOKEN" });

const { subscriber } = await lumail.subscribers.create({
  email: "subscriber@example.com",
  name: "John Doe",
  tags: ["newsletter"],
});
console.log(subscriber);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v1/subscribers \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email": "subscriber@example.com"}'
```

### JavaScript
```javascript
const response = await fetch('https://lumail.io/api/v1/subscribers', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer YOUR_API_TOKEN',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    email: 'subscriber@example.com',
    name: 'John Doe',
    tags: ['newsletter']
  }),
});

const data = await response.json();
console.log(data);
```

### Python
```python
import requests

url = "https://lumail.io/api/v1/subscribers"
headers = {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json"
}
payload = {
    "email": "subscriber@example.com",
    "name": "John Doe",
    "tags": ["newsletter"]
}

response = requests.post(url, json=payload, headers=headers)
data = response.json()
print(data)
```

### Success Response
```json
{
  "success": true,
  "subscriber": {
    "id": "sub_TTYPR5tWDh",
    "email": "demo@gmail.com",
    "name": null,
    "phone": null,
    "status": "SUBSCRIBED",
    "unsubscribeReason": null,
    "clickCount": 0,
    "openCount": 0,
    "revenue": 0,
    "organizationId": "HEnp-DaJnmI",
    "createdAt": "2025-04-27T04:49:56.381Z",
    "updatedAt": "2025-04-27T04:49:56.381Z",
    "tags": [
      {
        "id": "tag_JqaddtEx7z",
        "name": "demo1"
      },
      {
        "id": "tag_rdEPhyjnfA",
        "name": "demo2"
      }
    ],
    "fields": [
      {
        "id": "fld_vU4hI9WrWb",
        "name": "special-field",
        "value": "My special value"
      }
    ]
  }
}
```

### Error Response
```json
{
  "message": "We can't subscribe this email."
}
```

---

# Get Subscriber
Source: https://lumail.io/docs/api-reference/api-subscribers-get.md

`https://lumail.io/api/v1/subscribers/{subscriber}`

Retrieves a specific subscriber by ID or email address with their associated tags and custom fields.

## Response

- **Success (200 OK)** - Returns the subscriber object with their data.
- **Error (404 Not Found)** - Returned if the subscriber does not exist.

## Response Fields

| Field                    | Type           | Description                                                           |
| ------------------------ | -------------- | --------------------------------------------------------------------- |
| `success`                | boolean        | Indicates if the operation was successful                             |
| `subscriber`             | object         | The subscriber object with all its properties                         |
| `subscriber.id`          | string         | Unique identifier for the subscriber                                  |
| `subscriber.email`       | string         | Email address of the subscriber                                       |
| `subscriber.name`        | string or null | Name of the subscriber (if provided)                                  |
| `subscriber.phone`       | string or null | Phone number of the subscriber (if provided)                          |
| `subscriber.status`      | string         | Current status of the subscriber (e.g., "SUBSCRIBED", "UNSUBSCRIBED") |
| `subscriber.tags`        | array          | Array of tag objects associated with the subscriber                   |
| `subscriber.tags[].id`   | string         | Unique identifier for the tag                                         |
| `subscriber.tags[].name` | string         | Name of the tag                                                       |
| `subscriber.fields`      | object         | Custom field values associated with this subscriber                   |

## Request Schema

No request body is required for this endpoint. Use either the subscriber's unique ID or email address in the URL path.

## Usage Example

Use this API endpoint to retrieve detailed information about a specific subscriber, which can help you:

- Display subscriber profile information in your application
- Check a subscriber's current subscription status
- View which tags have been assigned to a subscriber
- Access custom field values for personalization
- Verify subscriber information before making updates


## API Reference
**Method:** GET
**Endpoint:** /api/v1/subscribers/{subscriber}

### SDK
```ts
import { Lumail } from "lumail";
const lumail = new Lumail({ apiKey: "YOUR_API_TOKEN" });

// Get by subscriber ID
const { subscriber } = await lumail.subscribers.get("sub_TTYPR5tWDh");

// Or by email
const { subscriber: sub } = await lumail.subscribers.get("subscriber@example.com");
console.log(sub);
```

### cURL
```bash
curl -X GET https://lumail.io/api/v1/subscribers/sub_TTYPR5tWDh \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json"

# Or using email
curl -X GET https://lumail.io/api/v1/subscribers/subscriber@example.com \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json"
```

### JavaScript
```javascript
// Using subscriber ID
const response = await fetch('https://lumail.io/api/v1/subscribers/sub_TTYPR5tWDh', {
  method: 'GET',
  headers: {
    'Authorization': 'Bearer YOUR_API_TOKEN',
    'Content-Type': 'application/json',
  },
});

const data = await response.json();
console.log(data);

// Or using email
const responseByEmail = await fetch('https://lumail.io/api/v1/subscribers/subscriber@example.com', {
  method: 'GET',
  headers: {
    'Authorization': 'Bearer YOUR_API_TOKEN',
    'Content-Type': 'application/json',
  },
});

const dataByEmail = await responseByEmail.json();
console.log(dataByEmail);
```

### Python
```python
import requests

# Using subscriber ID
url = "https://lumail.io/api/v1/subscribers/sub_TTYPR5tWDh"
headers = {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json"
}

response = requests.get(url, headers=headers)
data = response.json()
print(data)

# Or using email
url_email = "https://lumail.io/api/v1/subscribers/subscriber@example.com"
response_email = requests.get(url_email, headers=headers)
data_email = response_email.json()
print(data_email)
```

### Success Response
```json
{
  "success": true,
  "subscriber": {
    "id": "sub_TTYPR5tWDh",
    "email": "subscriber@example.com",
    "name": "John Doe",
    "phone": "+1234567890",
    "status": "SUBSCRIBED",
    "tags": [
      {
        "id": "tag_JqaddtEx7z",
        "name": "newsletter"
      },
      {
        "id": "tag_rdEPhyjnfA",
        "name": "promotional"
      }
    ],
    "fields": {
      "city": "New York",
      "company": "Acme Inc"
    }
  }
}
```

### Error Response
```json
{
  "message": "Subscriber not found"
}
```

---

# Update Subscriber
Source: https://lumail.io/docs/api-reference/api-subscribers-patch.md

`https://lumail.io/api/v1/subscribers/{subscriber}`

Updates an existing subscriber by ID or email address. You can modify their details, custom fields, and assigned tags.

## Response

- **Success (200 OK)** - Returns the updated subscriber object.
- **Error (404 Not Found)** - Returned if the subscriber does not exist.

## Response Fields

| Field                    | Type           | Description                                         |
| ------------------------ | -------------- | --------------------------------------------------- |
| `success`                | boolean        | Indicates if the operation was successful           |
| `subscriber`             | object         | The updated subscriber object                       |
| `subscriber.id`          | string         | Unique identifier for the subscriber                |
| `subscriber.email`       | string         | Email address of the subscriber                     |
| `subscriber.name`        | string or null | Name of the subscriber (if provided)                |
| `subscriber.phone`       | string or null | Phone number of the subscriber (if provided)        |
| `subscriber.status`      | string         | Current status of the subscriber                    |
| `subscriber.tags`        | array          | Array of tag objects associated with the subscriber |
| `subscriber.tags[].id`   | string         | Unique identifier for the tag                       |
| `subscriber.tags[].name` | string         | Name of the tag                                     |
| `subscriber.fields`      | object         | Custom field values associated with this subscriber |

## Request Schema

| Field              | Type     | Default  | Description                                                                                                                                                     |
| ------------------ | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `email`            | string   | required | Email address of the subscriber                                                                                                                                 |
| `name`             | string   | optional | Name of the subscriber                                                                                                                                          |
| `phone`            | string   | optional | Phone number of the subscriber                                                                                                                                  |
| `tags`             | string[] | `[]`     | Tag names to add to the subscriber                                                                                                                              |
| `fields`           | object   | optional | Custom field values (key-value pairs)                                                                                                                           |
| `replaceTags`      | boolean  | `false`  | If `true`, replaces all existing tags with the provided ones                                                                                                    |
| `resubscribe`      | boolean  | `false`  | If `true`, resubscribes the user if they were previously unsubscribed. By default, unsubscribed users stay unsubscribed                                         |
| `triggerWorkflows` | boolean  | `true`   | If `false`, skip triggering workflows even if subscriber's tags match workflow triggers. Useful for bulk updates or syncing data without triggering automations |
| `skipDoubleOptIn`  | boolean  | `false`  | If `true`, promote an existing `PENDING_CONFIRMATION` contact to `SUBSCRIBED` before tag events. Does not resubscribe `UNSUBSCRIBED` unless `resubscribe` is also true. |

## Usage Example

Use this API endpoint to update subscriber information such as:

- Update a subscriber's name or contact details
- Add or remove tags to change subscriber categorization
- Update custom field values for personalization or segmentation
- Modify subscriber information after form submissions or user profile updates
- Sync subscriber data between different systems

## Related Documentation

- [Add Tags to Subscriber](/docs/api-reference/api-subscriber-tags-post) - Add tags to subscribers
- [Remove Tags from Subscriber](/docs/api-reference/api-subscriber-tags-delete) - Remove tags from subscribers
- [Workflows](/docs/workflows) - Control workflow triggering with `triggerWorkflows`
- [Dynamic Promo Codes](/docs/tutorials/dynamic-promo-codes) - Update subscribers with webhook responses
- [Subscriber Events](/docs/features/subscriber-events) - View field update history


## API Reference
**Method:** PATCH
**Endpoint:** /api/v1/subscribers/{subscriber}

### SDK
```ts
import { Lumail } from "lumail";
const lumail = new Lumail({ apiKey: "YOUR_API_TOKEN" });

// Update by ID or email
const { subscriber } = await lumail.subscribers.update("subscriber@example.com", {
  name: "John Smith",
  tags: ["newsletter", "product-updates"],
  fields: { company: "New Company Inc" },
  replaceTags: true,
});
console.log(subscriber);
```

### cURL
```bash
curl -X PATCH https://lumail.io/api/v1/subscribers/sub_TTYPR5tWDh \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "John Smith",
    "tags": ["newsletter", "product-updates"],
    "fields": {
      "company": "New Company Inc"
    }
  }'

# Or using email
curl -X PATCH https://lumail.io/api/v1/subscribers/subscriber@example.com \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "John Smith",
    "tags": ["newsletter", "product-updates"],
    "replaceTags": true
  }'
```

### JavaScript
```javascript
// Using subscriber ID
const response = await fetch('https://lumail.io/api/v1/subscribers/sub_TTYPR5tWDh', {
  method: 'PATCH',
  headers: {
    'Authorization': 'Bearer YOUR_API_TOKEN',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'John Smith',
    tags: ['newsletter', 'product-updates'],
    fields: {
      company: 'New Company Inc'
    }
  }),
});

const data = await response.json();
console.log(data);
```

### Python
```python
import requests

url = "https://lumail.io/api/v1/subscribers/subscriber@example.com"
headers = {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json"
}
payload = {
    "name": "John Smith",
    "tags": ["newsletter", "product-updates"],
    "fields": {
        "company": "New Company Inc"
    },
    "replaceTags": True
}

response = requests.patch(url, json=payload, headers=headers)
data = response.json()
print(data)
```

### Success Response
```json
{
  "success": true,
  "subscriber": {
    "id": "sub_TTYPR5tWDh",
    "email": "subscriber@example.com",
    "name": "John Smith",
    "phone": "+1234567890",
    "status": "SUBSCRIBED",
    "tags": [
      {
        "id": "tag_JqaddtEx7z",
        "name": "newsletter"
      },
      {
        "id": "tag_kL9pqRs2tU",
        "name": "product-updates"
      }
    ],
    "fields": {
      "city": "New York",
      "company": "New Company Inc"
    }
  }
}
```

### Error Response
```json
{
  "message": "Subscriber not found"
}
```

---

# Unsubscribe Subscriber
Source: https://lumail.io/docs/api-reference/api-subscribers-unsubscribe.md

`https://lumail.io/api/v1/subscribers/{subscriber}/unsubscribe`

Marks the subscriber as unsubscribed and stops future mail. The subscriber row, send history, opens, clicks, and journal stay. Subscriber deletion is not a product operation; `DELETE /api/v1/subscribers/{subscriber}` returns 405.

## Response

- **Success (200 OK)** - Returns the unsubscribed subscriber.
- **Error (404 Not Found)** - Returned if the subscriber does not exist.

## Response Fields

| Field                | Type    | Description                                     |
| -------------------- | ------- | ----------------------------------------------- |
| `success`            | boolean | Indicates if the operation was successful       |
| `message`            | string  | Confirms the subscriber was unsubscribed        |
| `subscriber.id`      | string  | Unique identifier for the subscriber            |
| `subscriber.email`   | string  | Email address of the subscriber                 |
| `subscriber.status`  | string  | `UNSUBSCRIBED`, or the existing non-subscribed status |

## Request Schema

No request body is required. Identify the subscriber by unique ID or email address in the URL path.

## Usage Example

Use this endpoint when you need to:

- Stop sending to a subscriber who asked to leave
- Keep send history and deliverability data intact
- Mirror an unsubscribe from an integration


## API Reference
**Method:** POST
**Endpoint:** /api/v1/subscribers/{subscriber}/unsubscribe

### SDK
```ts
import { Lumail } from "lumail";
const lumail = new Lumail({ apiKey: "YOUR_API_TOKEN" });

const { subscriber } = await lumail.subscribers.unsubscribe(
  "subscriber@example.com",
);
console.log(subscriber);
```

### cURL
```bash
# Unsubscribe by ID
curl -X POST https://lumail.io/api/v1/subscribers/sub_TTYPR5tWDh/unsubscribe \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json"

# Or unsubscribe by email
curl -X POST https://lumail.io/api/v1/subscribers/subscriber@example.com/unsubscribe \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json"
```

### JavaScript
```javascript
const response = await fetch(
  'https://lumail.io/api/v1/subscribers/subscriber@example.com/unsubscribe',
  {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer YOUR_API_TOKEN',
      'Content-Type': 'application/json',
    },
  },
);

const data = await response.json();
console.log(data);
```

### Python
```python
import requests

url = "https://lumail.io/api/v1/subscribers/subscriber@example.com/unsubscribe"
headers = {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json"
}

response = requests.post(url, headers=headers)
data = response.json()
print(data)
```

### Success Response
```json
{
  "success": true,
  "message": "Subscriber unsubscribed successfully",
  "subscriber": {
    "id": "sub_TTYPR5tWDh",
    "email": "subscriber@example.com",
    "status": "UNSUBSCRIBED"
  }
}
```

### Error Response
```json
{
  "message": "Subscriber not found"
}
```

---

# Add Tags to Subscriber
Source: https://lumail.io/docs/api-reference/api-subscriber-tags-post.md

`https://lumail.io/api/v1/subscribers/{subscriber}/tags`

Add one or more tags to an existing subscriber. Tags that don't exist will be created automatically. If the subscriber already has a tag, it will be skipped (no duplicate tags).

## Response

- **Success (200 OK)** - Returns the list of tags that were processed.
- **Error (404 Not Found)** - Subscriber not found.
- **Error (400 Bad Request)** - Invalid request body or empty tags array.

## Response Fields

| Field         | Type    | Description                                                         |
| ------------- | ------- | ------------------------------------------------------------------- |
| `success`     | boolean | Indicates if the operation was successful                           |
| `added`       | number  | Number of new tags added (excludes tags the subscriber already had) |
| `tags`        | array   | List of all tags that were processed                                |
| `tags[].id`   | string  | Unique identifier for the tag                                       |
| `tags[].name` | string  | Name of the tag                                                     |

## Request Schema

| Field  | Type     | Default  | Description                                                                |
| ------ | -------- | -------- | -------------------------------------------------------------------------- |
| `tags` | string[] | required | Array of tag names to add to the subscriber. At least one tag is required. |

## Path Parameters

| Parameter    | Type   | Description                                                                                       |
| ------------ | ------ | ------------------------------------------------------------------------------------------------- |
| `subscriber` | string | Subscriber identifier - can be either the subscriber ID (e.g., `sub_TTYPR5tWDh`) or email address |

## Usage Example

Use this API endpoint to:

- Add tags to subscribers for segmentation and targeting
- Automatically tag subscribers based on external events (purchases, signups, etc.)
- Sync tags from your CRM or other systems
- Trigger workflows based on tag additions

## Related Documentation

- [Get All Tags](/docs/api-reference/api-tags-get) - List available tags
- [Create Tags](/docs/api-reference/api-tags-post) - Create new tags
- [Remove Tags from Subscriber](/docs/api-reference/api-subscriber-tags-delete) - Remove tags from subscribers
- [Workflows](/docs/workflows) - Trigger workflows when tags are added
- [Action Step](/docs/workflows/workflow-action-step) - Add tags within workflows
- [Subscriber Events](/docs/features/subscriber-events) - View tag change history


## API Reference
**Method:** POST
**Endpoint:** /api/v1/subscribers/{subscriber}/tags

### SDK
```ts
import { Lumail } from "lumail";
const lumail = new Lumail({ apiKey: "YOUR_API_TOKEN" });

const { added, tags } = await lumail.subscribers.addTags(
  "subscriber@example.com",
  ["newsletter", "premium"]
);
console.log(`Added ${added} tags:`, tags);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v1/subscribers/subscriber@example.com/tags \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"tags": ["newsletter", "premium"]}'
```

### JavaScript
```javascript
const response = await fetch('https://lumail.io/api/v1/subscribers/subscriber@example.com/tags', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer YOUR_API_TOKEN',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    tags: ['newsletter', 'premium']
  }),
});

const data = await response.json();
console.log(data);
```

### Python
```python
import requests

url = "https://lumail.io/api/v1/subscribers/subscriber@example.com/tags"
headers = {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json"
}
payload = {
    "tags": ["newsletter", "premium"]
}

response = requests.post(url, json=payload, headers=headers)
data = response.json()
print(data)
```

### Success Response
```json
{
  "success": true,
  "added": 2,
  "tags": [
    {
      "id": "tag_JqaddtEx7z",
      "name": "newsletter"
    },
    {
      "id": "tag_rdEPhyjnfA",
      "name": "premium"
    }
  ]
}
```

### Error Response
```json
{
  "message": "Subscriber not found"
}
```

---

# Remove Tags from Subscriber
Source: https://lumail.io/docs/api-reference/api-subscriber-tags-delete.md

`https://lumail.io/api/v1/subscribers/{subscriber}/tags`

Remove one or more tags from an existing subscriber. Tags that don't exist or that the subscriber doesn't have will be ignored.

## Response

- **Success (200 OK)** - Returns the list of tags that were actually removed.
- **Error (404 Not Found)** - Subscriber not found.
- **Error (400 Bad Request)** - Invalid request body or empty tags array.

## Response Fields

| Field         | Type    | Description                                                 |
| ------------- | ------- | ----------------------------------------------------------- |
| `success`     | boolean | Indicates if the operation was successful                   |
| `removed`     | number  | Number of tags actually removed from the subscriber         |
| `tags`        | array   | List of tags that were removed                              |
| `tags[].id`   | string  | Unique identifier for the tag                               |
| `tags[].name` | string  | Name of the tag                                             |
| `message`     | string  | (Optional) Additional information when no tags were removed |

## Request Schema

| Field  | Type     | Default  | Description                                                                     |
| ------ | -------- | -------- | ------------------------------------------------------------------------------- |
| `tags` | string[] | required | Array of tag names to remove from the subscriber. At least one tag is required. |

## Path Parameters

| Parameter    | Type   | Description                                                                                       |
| ------------ | ------ | ------------------------------------------------------------------------------------------------- |
| `subscriber` | string | Subscriber identifier - can be either the subscriber ID (e.g., `sub_TTYPR5tWDh`) or email address |

## Usage Example

Use this API endpoint to:

- Remove tags from subscribers when they no longer qualify for a segment
- Sync tag removals from your CRM or other systems
- Clean up subscriber data by removing obsolete tags
- Manage subscriber preferences and opt-outs

## Related Documentation

- [Get All Tags](/docs/api-reference/api-tags-get) - List available tags
- [Add Tags to Subscriber](/docs/api-reference/api-subscriber-tags-post) - Add tags to subscribers
- [Action Step](/docs/workflows/workflow-action-step) - Remove tags within workflows
- [Subscriber Events](/docs/features/subscriber-events) - View tag removal history


## API Reference
**Method:** DELETE
**Endpoint:** /api/v1/subscribers/{subscriber}/tags

### SDK
```ts
import { Lumail } from "lumail";
const lumail = new Lumail({ apiKey: "YOUR_API_TOKEN" });

const { removed, tags } = await lumail.subscribers.removeTags(
  "subscriber@example.com",
  ["newsletter", "premium"]
);
console.log(`Removed ${removed} tags:`, tags);
```

### cURL
```bash
curl -X DELETE https://lumail.io/api/v1/subscribers/subscriber@example.com/tags \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"tags": ["newsletter", "premium"]}'
```

### JavaScript
```javascript
const response = await fetch('https://lumail.io/api/v1/subscribers/subscriber@example.com/tags', {
  method: 'DELETE',
  headers: {
    'Authorization': 'Bearer YOUR_API_TOKEN',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    tags: ['newsletter', 'premium']
  }),
});

const data = await response.json();
console.log(data);
```

### Python
```python
import requests

url = "https://lumail.io/api/v1/subscribers/subscriber@example.com/tags"
headers = {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json"
}
payload = {
    "tags": ["newsletter", "premium"]
}

response = requests.delete(url, json=payload, headers=headers)
data = response.json()
print(data)
```

### Success Response
```json
{
  "success": true,
  "removed": 2,
  "tags": [
    {
      "id": "tag_JqaddtEx7z",
      "name": "newsletter"
    },
    {
      "id": "tag_rdEPhyjnfA",
      "name": "premium"
    }
  ]
}
```

### Error Response
```json
{
  "message": "Subscriber not found"
}
```

---

# Track Event
Source: https://lumail.io/docs/api-reference/api-events-post.md

`https://lumail.io/api/v1/events`

Records a new event for a subscriber. Events appear in the subscriber timeline and can be used for segmentation and automation triggers.

## Response

- **Success (200 OK)** - Returns the created event object.
- **Error (400 Bad Request)** - Returned if the subscriber is not found or the event data is invalid.
- **Error (500 Internal Server Error)** - Returned if the event could not be created.

## Response Fields

| Field                  | Type    | Description                                    |
| ---------------------- | ------- | ---------------------------------------------- |
| `success`              | boolean | Indicates if the operation was successful      |
| `event`                | object  | The created event object                       |
| `event.id`             | string  | Unique identifier for the event                |
| `event.eventType`      | string  | Type of the event                              |
| `event.subscriberId`   | string  | ID of the subscriber associated with the event |
| `event.organizationId` | string  | ID of the organization                         |
| `event.data`           | object  | Additional data specific to the event type     |
| `event.createdAt`      | string  | ISO timestamp when the event was created       |

## Request Schema

```json
{
  "eventType": "string", // Required: Type of event from predefined list
  "subscriber": "string", // Required: Email or ID of the subscriber
  "data": {
    // Required: Additional event data
    "property1": "value1",
    "property2": "value2"
  }
}
```

Supported event types:

- `SUBSCRIBED` - Subscriber joined a list
- `UNSUBSCRIBED` - Subscriber opted out
- `TAG_ADDED` - Tag was applied to subscriber
- `TAG_REMOVED` - Tag was removed from subscriber
- `EMAIL_OPENED` - Subscriber opened an email
- `EMAIL_CLICKED` - Subscriber clicked a link in an email
- `EMAIL_SENT` - Email was sent to subscriber
- `EMAIL_RECEIVED` - Email was successfully delivered
- `WORKFLOW_STARTED` - Subscriber entered a workflow
- `WORKFLOW_COMPLETED` - Subscriber completed a workflow
- `WORKFLOW_CANCELED` - Subscriber was removed from a workflow
- `FIELD_UPDATED` - Custom field was updated for subscriber
- `EMAIL_BOUNCED` - Email to subscriber bounced
- `EMAIL_COMPLAINED` - Subscriber marked email as spam
- `WEBHOOK_EXECUTED` - Webhook was triggered for subscriber
- `SUBSCRIBER_PAYMENT` - Subscriber made a payment
- `SUBSCRIBER_REFUND` - Subscriber received a refund

## Usage Example

Use this API endpoint to track important subscriber activities and interactions:

- Record purchase events with amount and product data
- Track website engagement like page visits or feature usage
- Log subscriber lifecycle events (registration, upgrade, cancellation)
- Store third-party system interactions
- Trigger automation workflows based on specific events
- Build a comprehensive timeline of subscriber activity

## Related Documentation

- [Subscriber Events Reference](/docs/features/subscriber-events) - Complete guide to all event types and their data fields
- [Get Subscriber Events API](/docs/api-reference/api-subscriber-events-get) - Retrieve event history for a subscriber


## API Reference
**Method:** POST
**Endpoint:** /api/v1/events

### SDK
```ts
import { Lumail } from "lumail";
const lumail = new Lumail({ apiKey: "YOUR_API_TOKEN" });

const { event } = await lumail.events.create({
  eventType: "SUBSCRIBER_PAYMENT",
  subscriber: "subscriber@example.com",
  data: {
    amount: 49.99,
    currency: "USD",
    productName: "Premium Subscription",
  },
});
console.log(event);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v1/events \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "eventType": "SUBSCRIBER_PAYMENT",
    "subscriber": "subscriber@example.com",
    "data": {
      "amount": 49.99,
      "currency": "USD",
      "productName": "Premium Subscription"
    }
  }'
```

### JavaScript
```javascript
const response = await fetch('https://lumail.io/api/v1/events', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer YOUR_API_TOKEN',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    eventType: 'SUBSCRIBER_PAYMENT',
    subscriber: 'subscriber@example.com',
    data: {
      amount: 49.99,
      currency: 'USD',
      productName: 'Premium Subscription'
    }
  }),
});

const data = await response.json();
console.log(data);
```

### Python
```python
import requests

url = "https://lumail.io/api/v1/events"
headers = {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json"
}
payload = {
    "eventType": "SUBSCRIBER_PAYMENT",
    "subscriber": "subscriber@example.com",
    "data": {
        "amount": 49.99,
        "currency": "USD",
        "productName": "Premium Subscription"
    }
}

response = requests.post(url, json=payload, headers=headers)
data = response.json()
print(data)
```

### Success Response
```json
{
  "success": true,
  "event": {
    "id": "evt_6fGhIjKl78",
    "eventType": "SUBSCRIBER_PAYMENT",
    "subscriberId": "sub_TTYPR5tWDh",
    "organizationId": "HEnp-DaJnmI",
    "data": {
      "amount": 49.99,
      "currency": "USD",
      "productName": "Premium Subscription"
    },
    "createdAt": "2025-04-27T06:12:34.567Z"
  }
}
```

### Error Response
```json
{
  "message": "Subscriber not found"
}
```

---

# Get Subscriber Events
Source: https://lumail.io/docs/api-reference/api-subscriber-events-get.md

`https://lumail.io/api/v1/subscribers/{subscriber}/events`

Retrieves the event history for a specific subscriber. This endpoint supports cursor-based pagination, filtering by event type, and date range filtering.

## Query Parameters

| Parameter    | Type   | Default | Description                                                   |
| ------------ | ------ | ------- | ------------------------------------------------------------- |
| `cursor`     | string | -       | Base64-encoded cursor for pagination                          |
| `take`       | number | 20      | Number of events to return (1-100)                            |
| `eventTypes` | string | -       | JSON array of event types to filter (e.g. `["EMAIL_OPENED"]`) |
| `order`      | string | "desc"  | Sort order by createdAt ("asc" or "desc")                     |
| `startDate`  | string | -       | ISO 8601 datetime to filter events after this date            |
| `endDate`    | string | -       | ISO 8601 datetime to filter events before this date           |
| `search`     | string | -       | Search query to filter events by data content or subscriber   |

## Response

- **Success (200 OK)** - Returns the events array with pagination cursor.
- **Error (404 Not Found)** - Returned if the subscriber does not exist.

## Response Fields

| Field                       | Type        | Description                                      |
| --------------------------- | ----------- | ------------------------------------------------ |
| `events`                    | array       | Array of event objects                           |
| `events[].id`               | string      | Unique identifier for the event                  |
| `events[].eventType`        | string      | Type of the event (see supported types below)    |
| `events[].data`             | object      | Event-specific data payload                      |
| `events[].subscriberId`     | string      | ID of the subscriber                             |
| `events[].createdAt`        | string      | ISO timestamp when the event occurred            |
| `events[].subscriber`       | object      | Subscriber details                               |
| `events[].subscriber.id`    | string      | Subscriber ID                                    |
| `events[].subscriber.email` | string      | Subscriber email                                 |
| `events[].subscriber.name`  | string/null | Subscriber name                                  |
| `nextCursor`                | string/null | Cursor for the next page (null if no more pages) |

## Supported Event Types

### Email Engagement Events

| Event Type               | Description                            | Data Fields                                                 |
| ------------------------ | -------------------------------------- | ----------------------------------------------------------- |
| `EMAIL_SENT`             | Email was sent to subscriber           | `emailId`, `campaignId`, `subject`, `workflow?`             |
| `EMAIL_RECEIVED`         | Email was successfully delivered       | `emailId`, `campaignId`, `subject`, `receivedAt?`           |
| `EMAIL_OPENED`           | Subscriber opened an email             | `emailId`, `campaignId?`, `subject`                         |
| `EMAIL_CLICKED`          | Subscriber clicked a link in an email  | `emailId`, `campaignId?`, `subject`, `targetUrl`, `linkId?` |
| `EMAIL_BOUNCED`          | Email could not be delivered           | `emailId`, `campaignId?`, `subject`, `reason`, `bounceType` |
| `EMAIL_COMPLAINED`       | Subscriber marked email as spam        | `emailId`, `campaignId?`, `subject`                         |
| `EMAIL_DELIVERY_DELAYED` | Email delivery was temporarily delayed | `emailId`, `campaignId?`, `subject`, `workflow?`            |

### Subscription Events

| Event Type     | Description                 | Data Fields                                          |
| -------------- | --------------------------- | ---------------------------------------------------- |
| `SUBSCRIBED`   | Subscriber joined your list | `tags?` (array of tag objects)                       |
| `UNSUBSCRIBED` | Subscriber opted out        | `reason?`, `campaignId?`, `campaignName?`, `status?` |

### Tag Events

| Event Type    | Description                     | Data Fields                   |
| ------------- | ------------------------------- | ----------------------------- |
| `TAG_ADDED`   | Tag was applied to subscriber   | `tagId`, `tagName`, `reason?` |
| `TAG_REMOVED` | Tag was removed from subscriber | `tagId`, `tagName`, `reason?` |

### Workflow Events

| Event Type           | Description                             | Data Fields                                                                                |
| -------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------ |
| `WORKFLOW_STARTED`   | Subscriber entered an automation        | `workflowId`, `workflowName`                                                               |
| `WORKFLOW_COMPLETED` | Subscriber completed all workflow steps | `workflowId`, `workflowName`                                                               |
| `WORKFLOW_CANCELED`  | Subscriber was removed from workflow    | `workflowId`, `workflowName`, `reason?`                                                    |
| `WEBHOOK_EXECUTED`   | Webhook step was triggered              | `workflowId`, `workflowName`, `stepId`, `webhookUrl`, `status`, `success`, `errorMessage?` |

### Revenue Events

| Event Type           | Description                | Data Fields         |
| -------------------- | -------------------------- | ------------------- |
| `SUBSCRIBER_PAYMENT` | Subscriber made a payment  | `amount`, `product` |
| `SUBSCRIBER_REFUND`  | Subscriber received refund | `amount`, `product` |

### Other Events

| Event Type      | Description                    | Data Fields                                     |
| --------------- | ------------------------------ | ----------------------------------------------- |
| `FIELD_UPDATED` | Custom field value was updated | `fieldId`, `fieldName`, `oldValue?`, `newValue` |

## Usage Examples

### Get recent activity

Retrieve the 20 most recent events for a subscriber:

```bash
curl "https://lumail.io/api/v1/subscribers/sub_123/events" \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### Filter by email engagement

Get only email open and click events:

```bash
curl 'https://lumail.io/api/v1/subscribers/sub_123/events?eventTypes=["EMAIL_OPENED","EMAIL_CLICKED"]' \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### Date range filtering

Get events from the last 7 days:

```bash
curl "https://lumail.io/api/v1/subscribers/sub_123/events?startDate=2025-01-01T00:00:00Z&endDate=2025-01-07T23:59:59Z" \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### Search events

Search for events containing specific text:

```bash
curl "https://lumail.io/api/v1/subscribers/sub_123/events?search=newsletter" \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### Pagination

Fetch all events using cursor pagination:

```javascript
async function getAllEvents(subscriberId) {
  const events = [];
  let cursor = null;

  do {
    const params = new URLSearchParams({ take: "100" });
    if (cursor) params.set("cursor", cursor);

    const response = await fetch(
      `https://lumail.io/api/v1/subscribers/${subscriberId}/events?${params}`,
      { headers: { Authorization: "Bearer YOUR_API_TOKEN" } },
    );
    const data = await response.json();

    events.push(...data.events);
    cursor = data.nextCursor;
  } while (cursor);

  return events;
}
```

### Pagination with SDK

```typescript
import { Lumail } from "lumail";
const lumail = new Lumail({ apiKey: "YOUR_API_TOKEN" });

async function getAllEvents(subscriberId: string) {
  const events = [];
  let cursor: string | null = null;

  do {
    const result = await lumail.subscribers.listEvents(subscriberId, {
      take: 100,
      cursor: cursor ?? undefined,
    });
    events.push(...result.events);
    cursor = result.nextCursor;
  } while (cursor);

  return events;
}
```

## Related Documentation

- [Track Events API](/docs/api-reference/api-events-post) - Record new events for subscribers
- [Subscriber Events Reference](/docs/features/subscriber-events) - Complete guide to all event types and their data fields
- [Revenue Tracking](/docs/features/revenue-tracking) - Analyze payment and refund events
- [Workflows](/docs/workflows) - View workflow execution history in events


## API Reference
**Method:** GET
**Endpoint:** /api/v1/subscribers/{subscriber}/events

### SDK
```ts
import { Lumail } from "lumail";
const lumail = new Lumail({ apiKey: "YOUR_API_TOKEN" });

// Basic request
const { events, nextCursor } = await lumail.subscribers.listEvents(
  "sub_TTYPR5tWDh",
  { take: 20, order: "desc" }
);

// With filters
const filtered = await lumail.subscribers.listEvents(
  "subscriber@example.com",
  {
    eventTypes: ["EMAIL_OPENED", "EMAIL_CLICKED"],
    startDate: "2025-01-01T00:00:00Z",
    take: 50,
    order: "desc",
  }
);

// Pagination
if (filtered.nextCursor) {
  const next = await lumail.subscribers.listEvents(
    "subscriber@example.com",
    { cursor: filtered.nextCursor, take: 50 }
  );
}
```

### cURL
```bash
curl -X GET "https://lumail.io/api/v1/subscribers/sub_TTYPR5tWDh/events?take=20&order=desc" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

# With event type filters (JSON array)
curl -X GET 'https://lumail.io/api/v1/subscribers/subscriber@example.com/events?eventTypes=["EMAIL_OPENED","EMAIL_CLICKED"]&startDate=2025-01-01T00:00:00Z' \
  -H "Authorization: Bearer YOUR_API_TOKEN"

# Pagination with cursor
curl -X GET "https://lumail.io/api/v1/subscribers/sub_TTYPR5tWDh/events?cursor=eyJjcmVhdGVkQXQiOiIyMDI1LTAxLTA3VDEyOjAwOjAwLjAwMFoiLCJpZCI6ImV2dF8xMjM0NSJ9" \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
// Basic request
const response = await fetch(
  'https://lumail.io/api/v1/subscribers/sub_TTYPR5tWDh/events?take=20',
  {
    headers: {
      'Authorization': 'Bearer YOUR_API_TOKEN',
    },
  }
);
const data = await response.json();
console.log(data);

// With filters and pagination
const params = new URLSearchParams({
  eventTypes: JSON.stringify(['EMAIL_OPENED', 'EMAIL_CLICKED']),
  startDate: '2025-01-01T00:00:00Z',
  take: '50',
  order: 'desc',
});

const filteredResponse = await fetch(
  `https://lumail.io/api/v1/subscribers/subscriber@example.com/events?${params}`,
  {
    headers: {
      'Authorization': 'Bearer YOUR_API_TOKEN',
    },
  }
);
const filteredData = await filteredResponse.json();

// Handle pagination
if (filteredData.nextCursor) {
  const nextPage = await fetch(
    `https://lumail.io/api/v1/subscribers/subscriber@example.com/events?cursor=${filteredData.nextCursor}`,
    {
      headers: {
        'Authorization': 'Bearer YOUR_API_TOKEN',
      },
    }
  );
}
```

### Python
```python
import requests
import json

# Basic request
url = "https://lumail.io/api/v1/subscribers/sub_TTYPR5tWDh/events"
headers = {
    "Authorization": "Bearer YOUR_API_TOKEN"
}

response = requests.get(url, headers=headers)
data = response.json()
print(data)

# With filters
params = {
    "eventTypes": json.dumps(["EMAIL_OPENED", "EMAIL_CLICKED"]),
    "startDate": "2025-01-01T00:00:00Z",
    "take": 50,
    "order": "desc"
}

filtered_response = requests.get(
    f"https://lumail.io/api/v1/subscribers/subscriber@example.com/events",
    params=params,
    headers=headers
)
filtered_data = filtered_response.json()

# Handle pagination
if filtered_data.get("nextCursor"):
    next_response = requests.get(
        f"https://lumail.io/api/v1/subscribers/subscriber@example.com/events",
        params={"cursor": filtered_data["nextCursor"]},
        headers=headers
    )
```

### Success Response
```json
{
  "events": [
    {
      "id": "evt_6fGhIjKl78",
      "eventType": "EMAIL_OPENED",
      "data": {
        "emailId": "eml_abc123",
        "campaignId": "cmp_xyz789",
        "subject": "Welcome to our newsletter!"
      },
      "subscriberId": "sub_TTYPR5tWDh",
      "createdAt": "2025-01-07T14:30:00.000Z",
      "subscriber": {
        "id": "sub_TTYPR5tWDh",
        "email": "subscriber@example.com",
        "name": "John Doe"
      }
    },
    {
      "id": "evt_9aBcDeFg12",
      "eventType": "EMAIL_SENT",
      "data": {
        "emailId": "eml_abc123",
        "campaignId": "cmp_xyz789",
        "subject": "Welcome to our newsletter!"
      },
      "subscriberId": "sub_TTYPR5tWDh",
      "createdAt": "2025-01-07T14:00:00.000Z",
      "subscriber": {
        "id": "sub_TTYPR5tWDh",
        "email": "subscriber@example.com",
        "name": "John Doe"
      }
    }
  ],
  "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI1LTAxLTA3VDEzOjQ1OjAwLjAwMFoiLCJpZCI6ImV2dF8zSGlKa0xtTjQ1In0="
}
```

### Error Response
```json
{
  "message": "Subscriber not found"
}
```

---

# Get All Tags
Source: https://lumail.io/docs/api-reference/api-tags-get.md

`https://lumail.io/api/v1/tags`

Retrieves all tags for your organization to help you categorize subscribers and target specific audiences for your campaigns.

## Response

- **Success (200 OK)** - Returns an array of tag objects.

## Response Fields

| Field         | Type    | Description                               |
| ------------- | ------- | ----------------------------------------- |
| `success`     | boolean | Indicates if the operation was successful |
| `tags`        | array   | Array of tag objects                      |
| `tags[].id`   | string  | Unique identifier for the tag             |
| `tags[].name` | string  | Name of the tag                           |

## Request Schema

No request body is required for this endpoint.

## Usage Example

Use this API endpoint to retrieve all tags in your organization, which can help you:

- Display all available tags in your application UI
- Create dropdown menus for tag selection in forms
- Sync tags between different systems or applications
- Get tag IDs needed for other API operations such as adding tags to subscribers
- Build tag-based filtering and segmentation tools


## API Reference
**Method:** GET
**Endpoint:** /api/v1/tags

### SDK
```ts
import { Lumail } from "lumail";
const lumail = new Lumail({ apiKey: "YOUR_API_TOKEN" });

const { tags } = await lumail.tags.list();
console.log(tags);
```

### cURL
```bash
curl -X GET https://lumail.io/api/v1/tags \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json"
```

### JavaScript
```javascript
const response = await fetch('https://lumail.io/api/v1/tags', {
  method: 'GET',
  headers: {
    'Authorization': 'Bearer YOUR_API_TOKEN',
    'Content-Type': 'application/json',
  },
});

const data = await response.json();
console.log(data);
```

### Python
```python
import requests

url = "https://lumail.io/api/v1/tags"
headers = {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json"
}

response = requests.get(url, headers=headers)
data = response.json()
print(data)
```

### Success Response
```json
{
  "success": true,
  "tags": [
    {
      "id": "tag_JqaddtEx7z",
      "name": "newsletter"
    },
    {
      "id": "tag_rdEPhyjnfA",
      "name": "promotional"
    },
    {
      "id": "tag_kL9pqRs2tU",
      "name": "welcome-sequence"
    }
  ]
}
```

### Error Response
```json
{
  "message": "Invalid API token"
}
```

---

# Create a Tag
Source: https://lumail.io/docs/api-reference/api-tags-post.md

`https://lumail.io/api/v1/tags`

Creates a new tag in your organization to help organize subscribers and create targeted segments for your email campaigns.

## Response

- **Success (200 OK)** - Returns the created tag object.
- **Error (400 Bad Request)** - Returned if the tag name is invalid or missing.

## Response Fields

| Field      | Type    | Description                               |
| ---------- | ------- | ----------------------------------------- |
| `success`  | boolean | Indicates if the operation was successful |
| `tag`      | object  | The created tag object                    |
| `tag.id`   | string  | Unique identifier for the tag             |
| `tag.name` | string  | Name of the tag                           |

## Request Schema

```json
{
  "name": "string" // Required: Name of the tag
}
```

## Usage Example

Use this API endpoint to create new tags for your organization, which can help you:

- Categorize subscribers based on interests, behavior, or demographics
- Set up tags for segmentation and targeting in email campaigns
- Organize subscribers for automation workflows
- Create logical groupings for reporting and analytics
- Implement tag-based features in your application


## API Reference
**Method:** POST
**Endpoint:** /api/v1/tags

### SDK
```ts
import { Lumail } from "lumail";
const lumail = new Lumail({ apiKey: "YOUR_API_TOKEN" });

const { tag } = await lumail.tags.create({ name: "newsletter" });
console.log(tag);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v1/tags \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "newsletter"}'
```

### JavaScript
```javascript
const response = await fetch('https://lumail.io/api/v1/tags', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer YOUR_API_TOKEN',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'newsletter',
  }),
});

const data = await response.json();
console.log(data);
```

### Python
```python
import requests

url = "https://lumail.io/api/v1/tags"
headers = {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json"
}
payload = {
    "name": "newsletter"
}

response = requests.post(url, json=payload, headers=headers)
data = response.json()
print(data)
```

### Success Response
```json
{
  "success": true,
  "tag": {
    "id": "tag_xY3zAb7cDe",
    "name": "newsletter"
  }
}
```

### Error Response
```json
{
  "message": "Invalid API token"
}
```

---

# List All Campaigns
Source: https://lumail.io/docs/api-reference/api-campaigns-get.md

`https://lumail.io/api/v1/campaigns`

Retrieve a paginated list of all campaigns in your organization. Filter by status and search by name or subject.

## Response

- **Success (200 OK)** - Returns a paginated array of campaign objects.
- **Error (401 Unauthorized)** - Invalid or missing API token.

## Query Parameters

| Parameter | Type   | Default | Description                                                       |
| --------- | ------ | ------- | ----------------------------------------------------------------- |
| `status`  | string | `all`   | Filter by status: `all`, `DRAFT`, `ARCHIVED`, `SCHEDULED`, `SENT` |
| `page`    | number | `1`     | Page number for pagination                                        |
| `limit`   | number | `20`    | Number of campaigns per page (max 100)                            |
| `query`   | string | -       | Search campaigns by name or subject                               |
| `sortBy`  | string | -       | Sort order: `name`, `name_desc` (default: by date)                |

## Response Fields

| Field                            | Type           | Description                                      |
| -------------------------------- | -------------- | ------------------------------------------------ |
| `success`                        | boolean        | Indicates if the operation was successful        |
| `campaigns`                      | array          | Array of campaign objects                        |
| `campaigns[].id`                 | string         | Unique identifier for the campaign               |
| `campaigns[].campaignId`         | string         | Same as id (for consistency)                     |
| `campaigns[].name`               | string         | Name of the campaign                             |
| `campaigns[].subject`            | string         | Email subject line                               |
| `campaigns[].preview`            | string or null | Preview text for the email                       |
| `campaigns[].status`             | string         | Campaign status: DRAFT, SCHEDULED, SENDING, SENT |
| `campaigns[].contentType`        | string         | Content type: MAILY, PLATE, or MARKDOWN          |
| `campaigns[].sendAt`             | string or null | ISO timestamp when the campaign was sent         |
| `campaigns[].scheduledAt`        | string or null | ISO timestamp when the campaign is scheduled     |
| `campaigns[].recipientCount`     | number         | Number of recipients                             |
| `campaigns[].emailsSentCount`    | number         | Number of emails sent                            |
| `campaigns[].emailsOpenedCount`  | number         | Number of emails opened                          |
| `campaigns[].emailsClickedCount` | number         | Number of emails clicked                         |
| `campaigns[].createdAt`          | string         | ISO timestamp when the campaign was created      |
| `campaigns[].updatedAt`          | string         | ISO timestamp when the campaign was last updated |
| `total`                          | number         | Total number of campaigns matching the filter    |
| `page`                           | number         | Current page number                              |
| `limit`                          | number         | Number of items per page                         |
| `pageCount`                      | number         | Total number of pages                            |

## Usage Example

Use this API endpoint to:

- Display all campaigns in your application dashboard
- Build campaign management interfaces
- Monitor campaign statuses and performance
- Search for specific campaigns by name or subject
- Implement pagination for large campaign lists

## Related Documentation

- [Create Campaign](/docs/api-reference/api-campaigns-post) - Create a new campaign
- [Get Campaign](/docs/api-reference/api-campaign-get) - Get details for a specific campaign
- [Update Campaign](/docs/api-reference/api-campaign-patch) - Update a campaign
- [Delete Campaign](/docs/api-reference/api-campaign-delete) - Delete a campaign


## API Reference
**Method:** GET
**Endpoint:** /api/v1/campaigns

### SDK
```ts
import { Lumail } from "lumail";
const lumail = new Lumail({ apiKey: "YOUR_API_TOKEN" });

const { campaigns, total, pageCount } = await lumail.campaigns.list({
  status: "DRAFT",
  page: 1,
  limit: 20,
});
console.log(campaigns);
```

### cURL
```bash
curl -X GET "https://lumail.io/api/v1/campaigns?status=DRAFT&page=1&limit=20" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json"
```

### JavaScript
```javascript
const response = await fetch('https://lumail.io/api/v1/campaigns?status=DRAFT&page=1&limit=20', {
  method: 'GET',
  headers: {
    'Authorization': 'Bearer YOUR_API_TOKEN',
    'Content-Type': 'application/json',
  },
});

const data = await response.json();
console.log(data);
```

### Python
```python
import requests

url = "https://lumail.io/api/v1/campaigns"
headers = {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json"
}
params = {
    "status": "DRAFT",
    "page": 1,
    "limit": 20
}

response = requests.get(url, headers=headers, params=params)
data = response.json()
print(data)
```

### Success Response
```json
{
  "success": true,
  "campaigns": [
    {
      "id": "cmp_abc123xyz",
      "campaignId": "cmp_abc123xyz",
      "name": "Welcome Newsletter",
      "subject": "Welcome to our community!",
      "preview": "We're excited to have you...",
      "status": "DRAFT",
      "contentType": "MAILY",
      "sendAt": null,
      "scheduledAt": null,
      "recipientCount": 0,
      "emailsSentCount": 0,
      "emailsOpenedCount": 0,
      "emailsClickedCount": 0,
      "createdAt": "2025-01-15T10:30:00.000Z",
      "updatedAt": "2025-01-15T14:20:00.000Z"
    }
  ],
  "total": 1,
  "page": 1,
  "limit": 20,
  "pageCount": 1
}
```

### Error Response
```json
{
  "message": "Invalid API token"
}
```

---

# Create Campaign
Source: https://lumail.io/docs/api-reference/api-campaigns-post.md

`https://lumail.io/api/v1/campaigns`

Create a new email campaign in your organization. The campaign is created with a DRAFT status and can be edited before scheduling or sending.

## Response

- **Success (200 OK)** - Returns the created campaign object.
- **Error (400 Bad Request)** - Validation error or no email sender configured.
- **Error (401 Unauthorized)** - Invalid or missing API token.

## Request Schema

| Field         | Type   | Default     | Description                                            |
| ------------- | ------ | ----------- | ------------------------------------------------------ |
| `subject`     | string | required    | Email subject line                                     |
| `name`        | string | `Untitled`  | Internal name for the campaign                         |
| `preview`     | string | optional    | Preview text shown in email clients                    |
| `content`     | object | boilerplate | Email content in TipTap JSON format                    |
| `contentType` | string | `MAILY`     | Content type: `MAILY`, `PLATE`, or `MARKDOWN`          |
| `senderId`    | string | auto        | Email sender ID (uses default sender if not specified) |
| `replyTo`     | string | optional    | Reply-to email address                                 |

## Response Fields

| Field                     | Type           | Description                                      |
| ------------------------- | -------------- | ------------------------------------------------ |
| `success`                 | boolean        | Indicates if the operation was successful        |
| `campaign`                | object         | The created campaign object                      |
| `campaign.id`             | string         | Unique identifier for the campaign               |
| `campaign.campaignId`     | string         | Same as id (for consistency)                     |
| `campaign.name`           | string         | Name of the campaign                             |
| `campaign.subject`        | string         | Email subject line                               |
| `campaign.preview`        | string or null | Preview text for the email                       |
| `campaign.status`         | string         | Campaign status (always DRAFT for new campaigns) |
| `campaign.contentType`    | string         | Content type: MAILY, PLATE, or MARKDOWN          |
| `campaign.content`        | object         | Email content in TipTap JSON format              |
| `campaign.replyTo`        | string or null | Reply-to email address                           |
| `campaign.senderId`       | string         | Email sender ID                                  |
| `campaign.recipientCount` | number         | Number of recipients (0 for new campaigns)       |
| `campaign.createdAt`      | string         | ISO timestamp when the campaign was created      |
| `campaign.updatedAt`      | string         | ISO timestamp when the campaign was last updated |
| `campaignId`              | string         | Campaign ID at the root level for convenience    |

## Content Format

The `content` field accepts TipTap JSON format. If not provided, a default template is used that includes:

- A "Hello, world!" paragraph
- An unsubscribe link (required for email compliance)

Example content structure:

```json
{
  "type": "doc",
  "content": [
    {
      "type": "paragraph",
      "content": [{ "type": "text", "text": "Hello, {{name}}!" }]
    }
  ]
}
```

## Usage Example

Use this API endpoint to:

- Create campaigns programmatically from your application
- Set up automated campaign creation workflows
- Build custom campaign management interfaces
- Import campaigns from other email marketing tools

## Related Documentation

- [List Campaigns](/docs/api-reference/api-campaigns-get) - List all campaigns
- [Get Campaign](/docs/api-reference/api-campaign-get) - Get details for a specific campaign
- [Update Campaign](/docs/api-reference/api-campaign-patch) - Update a campaign
- [Delete Campaign](/docs/api-reference/api-campaign-delete) - Delete a campaign
- [Variables](/docs/features/variables) - Use subscriber data in campaigns


## API Reference
**Method:** POST
**Endpoint:** /api/v1/campaigns

### SDK
```ts
import { Lumail } from "lumail";
const lumail = new Lumail({ apiKey: "YOUR_API_TOKEN" });

const { campaign, campaignId } = await lumail.campaigns.create({
  subject: "Welcome to our newsletter!",
  name: "My Newsletter",
  preview: "This is the preview text...",
});
console.log(campaign);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v1/campaigns \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "My Newsletter", "subject": "Welcome to our newsletter!"}'
```

### JavaScript
```javascript
const response = await fetch('https://lumail.io/api/v1/campaigns', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer YOUR_API_TOKEN',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'My Newsletter',
    subject: 'Welcome to our newsletter!',
    preview: 'This is the preview text...'
  }),
});

const data = await response.json();
console.log(data);
```

### Python
```python
import requests

url = "https://lumail.io/api/v1/campaigns"
headers = {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json"
}
payload = {
    "name": "My Newsletter",
    "subject": "Welcome to our newsletter!",
    "preview": "This is the preview text..."
}

response = requests.post(url, json=payload, headers=headers)
data = response.json()
print(data)
```

### Success Response
```json
{
  "success": true,
  "campaign": {
    "id": "cmp_abc123xyz",
    "campaignId": "cmp_abc123xyz",
    "name": "My Newsletter",
    "subject": "Welcome to our newsletter!",
    "preview": "This is the preview text...",
    "status": "DRAFT",
    "contentType": "MAILY",
    "content": {
      "type": "doc",
      "content": [...]
    },
    "replyTo": null,
    "senderId": "snd_xyz789abc",
    "recipientCount": 0,
    "createdAt": "2025-01-15T10:30:00.000Z",
    "updatedAt": "2025-01-15T10:30:00.000Z"
  },
  "campaignId": "cmp_abc123xyz"
}
```

### Error Response
```json
{
  "message": "No email sender found. Please create an email sender first."
}
```

---

# Get Campaign
Source: https://lumail.io/docs/api-reference/api-campaign-get.md

`https://lumail.io/api/v1/campaigns/{campaignId}`

Retrieve detailed information about a specific campaign, including its content, sender details, and performance metrics.

## Response

- **Success (200 OK)** - Returns the campaign object with full details.
- **Error (404 Not Found)** - Campaign not found or doesn't belong to your organization.
- **Error (401 Unauthorized)** - Invalid or missing API token.

## Path Parameters

| Parameter    | Type   | Description                          |
| ------------ | ------ | ------------------------------------ |
| `campaignId` | string | **Required.** The unique campaign ID |

## Response Fields

| Field                           | Type           | Description                                      |
| ------------------------------- | -------------- | ------------------------------------------------ |
| `success`                       | boolean        | Indicates if the operation was successful        |
| `campaign`                      | object         | The campaign object with full details            |
| `campaign.id`                   | string         | Unique identifier for the campaign               |
| `campaign.campaignId`           | string         | Same as id (for consistency)                     |
| `campaign.name`                 | string         | Name of the campaign                             |
| `campaign.subject`              | string         | Email subject line                               |
| `campaign.preview`              | string or null | Preview text for the email                       |
| `campaign.status`               | string         | Campaign status: DRAFT, SCHEDULED, SENDING, SENT |
| `campaign.contentType`          | string         | Content type: MAILY, PLATE, or MARKDOWN          |
| `campaign.content`              | object         | Email content in TipTap JSON format              |
| `campaign.replyTo`              | string or null | Reply-to email address                           |
| `campaign.senderId`             | string         | Email sender ID                                  |
| `campaign.scheduledAt`          | string or null | ISO timestamp when the campaign is scheduled     |
| `campaign.sendAt`               | string or null | ISO timestamp when the campaign was sent         |
| `campaign.timezone`             | string         | Timezone for scheduling                          |
| `campaign.recipientCount`       | number         | Number of recipients                             |
| `campaign.recipientFilters`     | array          | Filters applied to select recipients             |
| `campaign.emailsSentCount`      | number         | Number of emails sent                            |
| `campaign.emailsOpenedCount`    | number         | Number of emails opened                          |
| `campaign.emailsClickedCount`   | number         | Number of emails clicked                         |
| `campaign.createdAt`            | string         | ISO timestamp when the campaign was created      |
| `campaign.updatedAt`            | string         | ISO timestamp when the campaign was last updated |
| `campaign.sender`               | object         | Email sender details                             |
| `campaign.sender.id`            | string         | Sender ID                                        |
| `campaign.sender.name`          | string         | Sender name                                      |
| `campaign.sender.displayName`   | string         | Display name shown in emails                     |
| `campaign.sender.localPart`     | string         | Local part of the email address                  |
| `campaign.sender.replyTo`       | string or null | Reply-to address for the sender                  |
| `campaign.sender.domain.domain` | string         | Domain for the sender email                      |
| `campaignId`                    | string         | Campaign ID at the root level for convenience    |

## Usage Example

Use this API endpoint to:

- Retrieve full campaign details for editing
- Display campaign content in your application
- Check campaign status and performance metrics
- Get sender information for a campaign
- Build campaign preview functionality

## Related Documentation

- [List Campaigns](/docs/api-reference/api-campaigns-get) - List all campaigns
- [Create Campaign](/docs/api-reference/api-campaigns-post) - Create a new campaign
- [Update Campaign](/docs/api-reference/api-campaign-patch) - Update a campaign
- [Delete Campaign](/docs/api-reference/api-campaign-delete) - Delete a campaign


## API Reference
**Method:** GET
**Endpoint:** /api/v1/campaigns/{campaignId}

### SDK
```ts
import { Lumail } from "lumail";
const lumail = new Lumail({ apiKey: "YOUR_API_TOKEN" });

const { campaign } = await lumail.campaigns.get("cmp_abc123xyz");
console.log(campaign);
```

### cURL
```bash
curl -X GET https://lumail.io/api/v1/campaigns/cmp_abc123xyz \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json"
```

### JavaScript
```javascript
const campaignId = 'cmp_abc123xyz';
const response = await fetch(`https://lumail.io/api/v1/campaigns/${campaignId}`, {
  method: 'GET',
  headers: {
    'Authorization': 'Bearer YOUR_API_TOKEN',
    'Content-Type': 'application/json',
  },
});

const data = await response.json();
console.log(data);
```

### Python
```python
import requests

campaign_id = "cmp_abc123xyz"
url = f"https://lumail.io/api/v1/campaigns/{campaign_id}"
headers = {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json"
}

response = requests.get(url, headers=headers)
data = response.json()
print(data)
```

### Success Response
```json
{
  "success": true,
  "campaign": {
    "id": "cmp_abc123xyz",
    "campaignId": "cmp_abc123xyz",
    "name": "Welcome Newsletter",
    "subject": "Welcome to our community!",
    "preview": "We're excited to have you...",
    "status": "DRAFT",
    "contentType": "MAILY",
    "content": {
      "type": "doc",
      "content": [...]
    },
    "replyTo": null,
    "senderId": "snd_xyz789abc",
    "scheduledAt": null,
    "sendAt": null,
    "timezone": "Europe/Paris",
    "recipientCount": 1500,
    "recipientFilters": [],
    "emailsSentCount": 0,
    "emailsOpenedCount": 0,
    "emailsClickedCount": 0,
    "createdAt": "2025-01-15T10:30:00.000Z",
    "updatedAt": "2025-01-15T14:20:00.000Z",
    "sender": {
      "id": "snd_xyz789abc",
      "name": "Newsletter Sender",
      "displayName": "Acme Inc",
      "localPart": "newsletter",
      "replyTo": null,
      "domain": {
        "domain": "mail.acme.com"
      }
    }
  },
  "campaignId": "cmp_abc123xyz"
}
```

### Error Response
```json
{
  "message": "Campaign not found"
}
```

---

# Update Campaign
Source: https://lumail.io/docs/api-reference/api-campaign-patch.md

`https://lumail.io/api/v1/campaigns/{campaignId}`

Update an existing campaign's metadata and content. Only campaigns with DRAFT status can be updated. For scheduled campaigns, cancel the schedule first.

## Response

- **Success (200 OK)** - Returns the updated campaign object.
- **Error (400 Bad Request)** - Campaign is not in DRAFT status or invalid sender ID.
- **Error (404 Not Found)** - Campaign not found or doesn't belong to your organization.
- **Error (401 Unauthorized)** - Invalid or missing API token.

## Path Parameters

| Parameter    | Type   | Description                          |
| ------------ | ------ | ------------------------------------ |
| `campaignId` | string | **Required.** The unique campaign ID |

## Request Schema

| Field         | Type   | Description                                   |
| ------------- | ------ | --------------------------------------------- |
| `name`        | string | Internal name for the campaign                |
| `subject`     | string | Email subject line                            |
| `preview`     | string | Preview text shown in email clients           |
| `content`     | object | Email content in TipTap JSON format           |
| `contentType` | string | Content type: `MAILY`, `PLATE`, or `MARKDOWN` |
| `senderId`    | string | Email sender ID                               |
| `replyTo`     | string | Reply-to email address                        |

All fields are optional. Only provided fields will be updated.

## Response Fields

| Field                     | Type           | Description                                      |
| ------------------------- | -------------- | ------------------------------------------------ |
| `success`                 | boolean        | Indicates if the operation was successful        |
| `campaign`                | object         | The updated campaign object                      |
| `campaign.id`             | string         | Unique identifier for the campaign               |
| `campaign.campaignId`     | string         | Same as id (for consistency)                     |
| `campaign.name`           | string         | Name of the campaign                             |
| `campaign.subject`        | string         | Email subject line                               |
| `campaign.preview`        | string or null | Preview text for the email                       |
| `campaign.status`         | string         | Campaign status (DRAFT)                          |
| `campaign.contentType`    | string         | Content type: MAILY, PLATE, or MARKDOWN          |
| `campaign.content`        | object         | Email content in TipTap JSON format              |
| `campaign.replyTo`        | string or null | Reply-to email address                           |
| `campaign.senderId`       | string         | Email sender ID                                  |
| `campaign.recipientCount` | number         | Number of recipients                             |
| `campaign.createdAt`      | string         | ISO timestamp when the campaign was created      |
| `campaign.updatedAt`      | string         | ISO timestamp when the campaign was last updated |
| `campaignId`              | string         | Campaign ID at the root level for convenience    |

## Important Notes

- **Only DRAFT campaigns can be updated.** Scheduled, sending, or sent campaigns cannot be modified.
- To update a scheduled campaign, you must first cancel the schedule using the Lumail UI.
- When updating the `senderId`, the sender must exist and belong to your organization.

## Usage Example

Use this API endpoint to:

- Update campaign subject lines and preview text
- Modify email content programmatically
- Change the sender for a campaign
- Build campaign editing interfaces
- Sync campaign updates from external systems

## Related Documentation

- [List Campaigns](/docs/api-reference/api-campaigns-get) - List all campaigns
- [Create Campaign](/docs/api-reference/api-campaigns-post) - Create a new campaign
- [Get Campaign](/docs/api-reference/api-campaign-get) - Get details for a specific campaign
- [Delete Campaign](/docs/api-reference/api-campaign-delete) - Delete a campaign


## API Reference
**Method:** PATCH
**Endpoint:** /api/v1/campaigns/{campaignId}

### SDK
```ts
import { Lumail } from "lumail";
const lumail = new Lumail({ apiKey: "YOUR_API_TOKEN" });

await lumail.campaigns.update("cmp_abc123xyz", {
  subject: "Updated Subject Line",
  preview: "New preview text",
});
```

### cURL
```bash
curl -X PATCH https://lumail.io/api/v1/campaigns/cmp_abc123xyz \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"subject": "Updated Subject Line", "preview": "New preview text"}'
```

### JavaScript
```javascript
const campaignId = 'cmp_abc123xyz';
const response = await fetch(`https://lumail.io/api/v1/campaigns/${campaignId}`, {
  method: 'PATCH',
  headers: {
    'Authorization': 'Bearer YOUR_API_TOKEN',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    subject: 'Updated Subject Line',
    preview: 'New preview text'
  }),
});

const data = await response.json();
console.log(data);
```

### Python
```python
import requests

campaign_id = "cmp_abc123xyz"
url = f"https://lumail.io/api/v1/campaigns/{campaign_id}"
headers = {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json"
}
payload = {
    "subject": "Updated Subject Line",
    "preview": "New preview text"
}

response = requests.patch(url, json=payload, headers=headers)
data = response.json()
print(data)
```

### Success Response
```json
{
  "success": true,
  "campaign": {
    "id": "cmp_abc123xyz",
    "campaignId": "cmp_abc123xyz",
    "name": "Welcome Newsletter",
    "subject": "Updated Subject Line",
    "preview": "New preview text",
    "status": "DRAFT",
    "contentType": "MAILY",
    "content": {
      "type": "doc",
      "content": [...]
    },
    "replyTo": null,
    "senderId": "snd_xyz789abc",
    "recipientCount": 0,
    "createdAt": "2025-01-15T10:30:00.000Z",
    "updatedAt": "2025-01-15T16:45:00.000Z"
  },
  "campaignId": "cmp_abc123xyz"
}
```

### Error Response
```json
{
  "message": "Only DRAFT campaigns can be updated. Cancel the schedule first if the campaign is scheduled."
}
```

---

# Delete Campaign
Source: https://lumail.io/docs/api-reference/api-campaign-delete.md

`https://lumail.io/api/v1/campaigns/{campaignId}`

Permanently delete a campaign from your organization. Only campaigns with DRAFT status can be deleted.

## Response

- **Success (200 OK)** - Returns confirmation of deletion.
- **Error (400 Bad Request)** - Campaign is not in DRAFT status.
- **Error (404 Not Found)** - Campaign not found or doesn't belong to your organization.
- **Error (401 Unauthorized)** - Invalid or missing API token.

## Path Parameters

| Parameter    | Type   | Description                          |
| ------------ | ------ | ------------------------------------ |
| `campaignId` | string | **Required.** The unique campaign ID |

## Response Fields

| Field        | Type    | Description                               |
| ------------ | ------- | ----------------------------------------- |
| `success`    | boolean | Indicates if the operation was successful |
| `deleted`    | boolean | Confirms the campaign was deleted         |
| `campaignId` | string  | The ID of the deleted campaign            |

## Important Notes

- **Only DRAFT campaigns can be deleted.** Scheduled, sending, sent, or archived campaigns cannot be deleted via the API.
- **This action is permanent.** Deleted campaigns cannot be recovered.
- For campaigns you want to keep but hide, consider using the archive feature in the Lumail UI instead.
- Deleting a campaign does not affect any emails that have already been sent from that campaign.

## Usage Example

Use this API endpoint to:

- Clean up unused draft campaigns
- Remove test campaigns
- Build campaign management interfaces with delete functionality
- Automate cleanup of old draft campaigns

## Related Documentation

- [List Campaigns](/docs/api-reference/api-campaigns-get) - List all campaigns
- [Create Campaign](/docs/api-reference/api-campaigns-post) - Create a new campaign
- [Get Campaign](/docs/api-reference/api-campaign-get) - Get details for a specific campaign
- [Update Campaign](/docs/api-reference/api-campaign-patch) - Update a campaign


## API Reference
**Method:** DELETE
**Endpoint:** /api/v1/campaigns/{campaignId}

### SDK
```ts
import { Lumail } from "lumail";
const lumail = new Lumail({ apiKey: "YOUR_API_TOKEN" });

await lumail.campaigns.delete("cmp_abc123xyz");
```

### cURL
```bash
curl -X DELETE https://lumail.io/api/v1/campaigns/cmp_abc123xyz \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json"
```

### JavaScript
```javascript
const campaignId = 'cmp_abc123xyz';
const response = await fetch(`https://lumail.io/api/v1/campaigns/${campaignId}`, {
  method: 'DELETE',
  headers: {
    'Authorization': 'Bearer YOUR_API_TOKEN',
    'Content-Type': 'application/json',
  },
});

const data = await response.json();
console.log(data);
```

### Python
```python
import requests

campaign_id = "cmp_abc123xyz"
url = f"https://lumail.io/api/v1/campaigns/{campaign_id}"
headers = {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json"
}

response = requests.delete(url, headers=headers)
data = response.json()
print(data)
```

### Success Response
```json
{
  "success": true,
  "deleted": true,
  "campaignId": "cmp_abc123xyz"
}
```

### Error Response
```json
{
  "message": "Only DRAFT campaigns can be deleted. Archive the campaign instead if you want to keep it."
}
```

---

# Send Campaign
Source: https://lumail.io/docs/api-reference/api-campaign-send.md

`POST https://lumail.io/api/v1/campaigns/{campaignId}/send`

Send a campaign immediately or schedule it for later delivery. Only campaigns in DRAFT status can be sent.

## Response

- **Success (200 OK)** - Campaign has been scheduled for sending.
- **Error (400 Bad Request)** - Campaign is not in DRAFT status, missing unsubscribe link, or scheduledAt is in the past.
- **Error (400 Bad Request)** - Campaign content verification detected cold email or phishing, or the verification service was unavailable. The response includes `code`, `blockReasons`, `badges`, `documentationUrl`, and `guidance`.
- **Error (404 Not Found)** - Campaign not found or doesn't belong to your organization.
- **Error (401 Unauthorized)** - Invalid or missing API token.

## Path Parameters

| Parameter    | Type   | Description                          |
| ------------ | ------ | ------------------------------------ |
| `campaignId` | string | **Required.** The unique campaign ID |

## Request Body

| Field         | Type   | Default | Description                                                             |
| ------------- | ------ | ------- | ----------------------------------------------------------------------- |
| `scheduledAt` | string | —       | ISO 8601 datetime for scheduled delivery. Omit to send immediately.     |
| `timezone`    | string | `"UTC"` | Timezone for scheduling (e.g., `"Europe/Paris"`, `"America/New_York"`). |

## Response Fields

| Field                  | Type    | Description                                |
| ---------------------- | ------- | ------------------------------------------ |
| `success`              | boolean | Indicates if the operation was successful  |
| `campaign.id`          | string  | The campaign ID                            |
| `campaign.status`      | string  | Updated status (always `"SCHEDULED"`)      |
| `campaign.scheduledAt` | string  | The scheduled send time in ISO 8601 format |
| `campaign.timezone`    | string  | The timezone used for scheduling           |
| `campaignId`           | string  | The campaign ID (convenience field)        |

## Important Notes

- **Only DRAFT campaigns can be sent.** Scheduled, sending, sent, or archived campaigns cannot be sent again.
- **Unsubscribe link required.** Your campaign content must include `{{unsubscribeUrl}}`. The API will return an error if it's missing.
- **Content verification required.** API sends use the same JEV policy as the Lumail editor and AI tools. Cold email and phishing are unsupported. A blocked response identifies the failed checks and links to the [campaign content verification rules](/docs/deliverability/campaign-content-verification).
- **Omit `scheduledAt` to send immediately.** The campaign will begin sending right away.
- **`scheduledAt` must be in the future.** Past dates will return a 400 error.
- Once sent, the campaign status transitions: `DRAFT` → `SCHEDULED` → `SENDING` → `SENT`.

## Usage Example

Use this API endpoint to:

- Trigger campaign sends from your own application or CRM
- Schedule campaigns for optimal delivery times
- Build automated campaign workflows with external tools
- Integrate campaign sending into CI/CD or marketing automation pipelines

## Related Documentation

- [List Campaigns](/docs/api-reference/api-campaigns-get) - List all campaigns
- [Create Campaign](/docs/api-reference/api-campaigns-post) - Create a new campaign
- [Get Campaign](/docs/api-reference/api-campaign-get) - Get details for a specific campaign
- [Update Campaign](/docs/api-reference/api-campaign-patch) - Update a campaign
- [Delete Campaign](/docs/api-reference/api-campaign-delete) - Delete a campaign


## API Reference
**Method:** POST
**Endpoint:** /api/v1/campaigns/{campaignId}/send

### SDK
```ts
import { Lumail } from "lumail";
const lumail = new Lumail({ apiKey: "YOUR_API_TOKEN" });

// Send immediately
await lumail.campaigns.send("cmp_abc123xyz");

// Schedule for later
await lumail.campaigns.send("cmp_abc123xyz", {
  scheduledAt: "2025-03-15T14:00:00.000Z",
  timezone: "Europe/Paris",
});
```

### cURL
```bash
# Send immediately
curl -X POST https://lumail.io/api/v1/campaigns/cmp_abc123xyz/send \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'

# Schedule for later
curl -X POST https://lumail.io/api/v1/campaigns/cmp_abc123xyz/send \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "scheduledAt": "2025-03-15T14:00:00.000Z",
    "timezone": "Europe/Paris"
  }'
```

### JavaScript
```javascript
const campaignId = 'cmp_abc123xyz';

// Send immediately
const response = await fetch(`https://lumail.io/api/v1/campaigns/${campaignId}/send`, {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer YOUR_API_TOKEN',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({}),
});

// Schedule for later
const response = await fetch(`https://lumail.io/api/v1/campaigns/${campaignId}/send`, {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer YOUR_API_TOKEN',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    scheduledAt: '2025-03-15T14:00:00.000Z',
    timezone: 'Europe/Paris',
  }),
});

const data = await response.json();
console.log(data);
```

### Python
```python
import requests

campaign_id = "cmp_abc123xyz"
url = f"https://lumail.io/api/v1/campaigns/{campaign_id}/send"
headers = {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json"
}

# Send immediately
response = requests.post(url, headers=headers, json={})

# Schedule for later
response = requests.post(url, headers=headers, json={
    "scheduledAt": "2025-03-15T14:00:00.000Z",
    "timezone": "Europe/Paris"
})

data = response.json()
print(data)
```

### Success Response
```json
{
  "success": true,
  "campaign": {
    "id": "cmp_abc123xyz",
    "status": "SCHEDULED",
    "scheduledAt": "2025-03-15T14:00:00.000Z",
    "timezone": "Europe/Paris",
    "campaignId": "cmp_abc123xyz"
  },
  "campaignId": "cmp_abc123xyz"
}
```

### Error Response
```json
{
  "message": "Campaign must be in DRAFT status to send. Current status: SENT"
}
```

---

# API v2
Source: https://lumail.io/docs/api-reference/v2.md

- Base URL: `https://lumail.io/api/v2`
- Auth: `Authorization: Bearer lum_...` ([create a token](/docs/api-reference/api-tokens))
- Email body: exactly one of `html`, `tiptap`, or `markdown`
- Rewrite a v1 integration: [Migration to V2](/docs/api-reference/v2/migration)
- Spec: [openapi.json](https://lumail.io/openapi.json)
- Limits: [Rate limits](/docs/api-reference/api-limits)

## Send an email

```bash
curl -X POST https://lumail.io/api/v2/emails \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "hello@yourdomain.com",
    "to": "user@example.com",
    "subject": "Welcome",
    "html": "<p>Hello</p>"
  }'
```

Returns `{ "id": "eml_..." }`. Full contract: [Send Email](/docs/api-reference/v2/emails-send).

> **Test-mode recipients.** Fixture addresses such as `user@example.com` never send real email. See [Test-mode recipients](/docs/api-reference/test-mode).

Every v2 error uses `{ name, message, statusCode }` plus optional `details`. `429` includes `Retry-After`. Lists use `{ object: "list", data, has_more }` with `limit` / `after` / `before`.

## Resources

<DocCardWrapper>
  <DocCardGrid>
    <DocCard
      href="/docs/api-reference/v2/verify"
      icon="CheckCircle"
      title="Verify"
      description="Check an address before you store or send to it."
    />
    <DocCard
      href="/docs/api-reference/v2/emails"
      icon="Mail"
      title="Emails"
      description="Send, batch, and inspect transactional mail."
    />
    <DocCard
      href="/docs/api-reference/v2/subscribers"
      icon="Users"
      title="Subscribers"
      description="List, create, tag, and unsubscribe contacts."
    />
    <DocCard
      href="/docs/api-reference/v2/tags"
      icon="Tag"
      title="Tags"
      description="List, create, and rename audience tags."
    />
    <DocCard
      href="/docs/api-reference/v2/events"
      icon="Webhook"
      title="Events"
      description="Record a payment or refund on a subscriber."
    />
    <DocCard
      href="/docs/api-reference/v2/domains"
      icon="Globe"
      title="Domains"
      description="Create and verify sending domains."
    />
    <DocCard
      href="/docs/api-reference/v2/campaigns"
      icon="FileText"
      title="Campaigns"
      description="Draft, edit, schedule, and delete campaigns."
    />
    <DocCard
      href="/docs/api-reference/v2/tokens"
      icon="Key"
      title="Tokens"
      description="List and mint organization lum_ tokens."
    />
    <DocCard
      href="/docs/api-reference/v2/tools"
      icon="Settings"
      title="Tools"
      description="Same operations as MCP, over HTTP."
    />
  </DocCardGrid>
</DocCardWrapper>

---

# Migration to V2
Source: https://lumail.io/docs/api-reference/v2/migration.md

v1 HTTP is frozen. New work uses `/api/v2`. Same `lum_` Bearer token. Same organization.

If you are an agent rewriting code, follow this page in order. Do not invent fields. Do not keep `content` / `contentType`. Do not wrap v2 JSON in `success: true`.

<AgentPrompt
  label="Rewrite this repo to API v2"
  prompt="Read https://lumail.io/docs/api-reference/v2/migration.md then https://lumail.io/openapi.json. Replace every Lumail /api/v1 call with the v2 equivalent on this page. Rewrite send bodies to exactly one of html, tiptap, or markdown. Parse v2 JSON as documented (no success wrapper). Upgrade lumail to 2.1.0 if the TypeScript SDK is used and handle { data, error } results. Leave SMTP, /api/v1/integrations/*, /api/v1/admin/tools, MCP, and the CLI tools runner unchanged. Show the diff and a smoke-test command. Do not send a campaign."
/>

## Do this first

1. Find every `lumail.io/api/v1`, `https://api.lumail.io/v1`, and `lumail.emails.send({ content`.
2. Classify each hit: REST send, REST other, TypeScript SDK, SMTP, webhook URL, MCP/CLI.
3. Rewrite REST and SDK only. Stop if the hit is SMTP, `/api/v1/integrations/`, or `/api/v1/admin/tools`.
4. After the rewrite, send one test email with [Send Email](/docs/api-reference/v2/emails-send), then [Get Email](/docs/api-reference/v2/emails-get) by the returned `id`.

Machine-readable contract: [openapi.json](https://lumail.io/openapi.json). Endpoint catalog: [API v2](/docs/api-reference/v2). SDK: [`lumail@2.1.0`](/docs/sdk/v2/introduction).

## Path map

Prefix every path with `https://lumail.io`. Auth stays `Authorization: Bearer lum_...`.

| v1 | v2 |
| -- | -- |
| `POST /api/v1/emails` | `POST /api/v2/emails` |
| `POST /api/v1/emails/verify` | `POST /api/v2/verify` (`lumail.verify()`). `POST /api/v2/emails/verify` still returns `{ valid, error, code }`. |
| `POST /api/v1/subscribers` | `POST /api/v2/subscribers` |
| `GET /api/v1/subscribers` | `GET /api/v2/subscribers` |
| `GET /api/v1/subscribers/{subscriber}` | `GET /api/v2/subscribers/{subscriber}` |
| `PATCH /api/v1/subscribers/{subscriber}` | `PATCH /api/v2/subscribers/{subscriber}` |
| `POST /api/v1/subscribers/{subscriber}/unsubscribe` | `POST /api/v2/subscribers/{subscriber}/unsubscribe` |
| `POST /api/v1/subscribers/{subscriber}/tags` | `POST /api/v2/subscribers/{subscriber}/tags` |
| `DELETE /api/v1/subscribers/{subscriber}/tags` | `DELETE /api/v2/subscribers/{subscriber}/tags` |
| `GET /api/v1/subscribers/{subscriber}/events` | `GET /api/v2/subscribers/{subscriber}/events` |
| `POST /api/v1/events` | `POST /api/v2/events` |
| `GET /api/v1/tags` | `GET /api/v2/tags` |
| `POST /api/v1/tags` | `POST /api/v2/tags` |
| `GET /api/v1/tags/{tag}` | `GET /api/v2/tags/{tag}` |
| `GET /api/v1/campaigns` | `GET /api/v2/campaigns` |
| `POST /api/v1/campaigns` | `POST /api/v2/campaigns` |
| `GET /api/v1/campaigns/{campaignId}` | `GET /api/v2/campaigns/{campaignId}` |
| `PATCH /api/v1/campaigns/{campaignId}` | `PATCH /api/v2/campaigns/{campaignId}` |
| `DELETE /api/v1/campaigns/{campaignId}` | `DELETE /api/v2/campaigns/{campaignId}` |
| `POST /api/v1/campaigns/{campaignId}/send` | `POST /api/v2/campaigns/{campaignId}/send` |

v2-only (no v1 equivalent): `GET /api/v2/emails`, `GET /api/v2/emails/{id}`, `POST /api/v2/emails/batch`, domains (`/api/v2/domains`), tokens (`/api/v2/tokens`), tools (`/api/v2/tools`).

Leave unchanged: `smtp.lumail.io`, `POST /api/v1/integrations/*`, `GET|POST /api/v1/admin/tools`, `https://lumail.io/mcp`, `lumail tools run`.

## Send email (the breaking change)

v1 body used `content` + optional `contentType` (`MARKDOWN` default, `HTML`, `TIPTAP`) and returned `{ success, message, id }`.

v2 requires exactly one of `html`, `tiptap`, or `markdown`. Response is `{ "id": "eml_..." }`.

| v1 | v2 |
| -- | -- |
| `content` + omitted `contentType` or `MARKDOWN` | `markdown` |
| `content` + `contentType: "HTML"` | `html` |
| `content` + `contentType: "TIPTAP"` | `tiptap` |
| `to` string | `to` string or one-address array |
| `replyTo` | `reply_to` |
| `tracking` | drop it |
| `preview` | `preview` (unchanged) |
| `{ success, message, id }` | `{ id }` |

Drop these if present: `content`, `contentType`, `attachments`, `template`, `topic_id`, `scheduled_at`, `react`.

```javascript
// v1
await fetch("https://lumail.io/api/v1/emails", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    from: "hello@yourdomain.com",
    to: "user@example.com",
    subject: "Welcome",
    content: "# Hello {{name}}",
    contentType: "MARKDOWN",
  }),
});

// v2
const response = await fetch("https://lumail.io/api/v2/emails", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    from: "hello@yourdomain.com",
    to: "user@example.com",
    subject: "Welcome",
    markdown: "Hello **there**.",
  }),
});
const { id } = await response.json();
```

> **Test-mode recipients.** Fixture addresses such as `user@example.com` never send real email. See [Test-mode recipients](/docs/api-reference/test-mode).

After send, poll or fetch `GET /api/v2/emails/{id}` for `last_event`, `html`, and `text`. List (`GET /api/v2/emails`) does not return bodies.

Every v2 error is `{ name, message, statusCode }` plus optional `details`. `429` includes `Retry-After`.

## Responses and lists

Do not read `success` wrappers from v2 JSON.

| v1 | v2 |
| -- | -- |
| `{ success: true, id, message }` on send | `{ id }` |
| `{ success: true, subscriber }` on create/get | the subscriber object |
| `{ success: true, subscribers, total }` on list | `{ object: "list", data, has_more }` with `limit` / `after` / `before` |
| `{ success: true, campaign, campaignId }` | the campaign object |
| `page` or `cursor` query | `after` / `before` |

`GET /api/v2/subscribers` query: `limit`, `after`, `status`, `tag`, `query`.

## TypeScript SDK

`lumail@2.1.0` talks only to v2 and returns `{ data, error }`.

```bash
pnpm add lumail@2.1.0
```

```typescript
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });

const { data, error } = await lumail.emails.send(
  {
    from: "hello@yourdomain.com",
    to: "user@example.com",
    subject: "Welcome",
    html: "<p>Hello</p>",
  },
  { idempotencyKey: "welcome-1" },
);
if (error) throw new Error(error.message);
const id = data.id;
```

Replace `emails.send({ content, contentType, replyTo, tracking })` with `html` | `markdown` | `tiptap` and `reply_to`. See [SDK errors](/docs/sdk/v2/errors).

MCP, ChatGPT, Cursor, and `lumail tools run` already call the live tool catalog. Do not version those URLs.

## Checklist

- No remaining `/api/v1/` in application code except integrations and admin tools
- No `content` / `contentType` on send
- Send response handled as `{ id }`
- `lumail` dependency is `2.1.0` if the SDK is used
- One test send returned an `eml_` id and Get Email returns that row
- No campaign send as part of the migration

## Related

- [Send Email](/docs/api-reference/v2/emails-send)
- [Get Email](/docs/api-reference/v2/emails-get)
- [Create Subscriber](/docs/api-reference/v2/subscribers-create)
- [SDK 2.1.0](/docs/sdk/v2/introduction)
- [v1 API reference](/docs/api-reference) (frozen)

---

# Emails
Source: https://lumail.io/docs/api-reference/v2/emails.md

`POST /api/v2/emails` queues one transactional message. Batch accepts up to 100 of the same objects. List returns metadata and `last_event`; fetch the body with Get.

- Provide exactly one of `html`, `tiptap`, or `markdown`. Markdown is rendered to HTML.
- Omit `text` to generate it from the rendered HTML. `text: ""` skips generation.
- `to` is a string or a one-address array.
- Send and batch errors: `{ name, message, statusCode }`.

---

# Verify
Source: https://lumail.io/docs/api-reference/v2/verify.md

`POST /api/v2/verify` · `lumail.verify()`

Checks syntax, disposable and blocked domains, free mailbox providers, privacy aliases, recurring suspicious domains, spam-like or role-based mailbox names, domain typos, trusted global history, and DNS mail routing. Permission: `emails`. Dashboard, API, CLI, and `verify_email` share one monthly organization quota.

HTTP 200 for both accept and reject. Transport / auth failures use `{ name, message, statusCode }`. Every problem lives in `issues`. `suggestion` is the corrected address when the domain looks like a typo (`user@gmial.com` → `user@gmail.com`).

`accept` is the policy decision. It can be `true` when the monthly quota is exhausted (`status: "skipped"`, `issues[0].code: "quota_exceeded"`, `score: null`). That fallback skips history and DNS and does not change sending eligibility elsewhere.

DNS routing does not prove the mailbox exists. A clean address on a domain that routes mail is `result: "risky"` with `accept: true`. `result: "deliverable"` only appears on historical SMTP-confirmed rows.

Disposable domains are rejected with `disposable_email`. Free providers are informational. Privacy aliases stay accepted and return a `privacy_alias` warning because they can forward to a real mailbox. Recurring suspicious domains return `suspicious_domain` for manual review without being rejected. These list checks are local and do not call another verification service.

## Score

`score` is a deterministic 0–100 confidence from the signals that ran. It is not a delivery guarantee. Use it to rank or threshold addresses; use `accept` for the hard policy decision.

| Range  | Meaning                                                                                                                                                                  |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 80     | Every executed check passed (syntax, lists, history, DNS routing). Mailbox not proven. `result: "risky"`.                                                                |
| 90+    | Historical SMTP-confirmed rows only.                                                                                                                                     |
| 30     | DNS could not be resolved. `result: "unknown"`.                                                                                                                          |
| 0–15   | Rejected, including no mail destination and null MX. `mailbox_full` scores 15 and `previously_complained` 5 because the mailbox existed when those events were recorded. |
| `null` | Nothing was checked (quota exhausted or internal failure).                                                                                                               |

Spam-like mailbox names subtract 30, domain typos 35, role-based mailboxes (`info@`, `support@`) 10.

## Routing cache

MX + resolved addresses are cached per domain (12 h; 7 days for pinned consumer providers such as Gmail, Outlook, Yahoo, iCloud, Orange, Free, SFR, La Poste, Proton, and GMX). Temporary DNS failures are not cached. No SMTP session is opened and no email is sent.

## Body Parameters

| Name    | Type   | Description                     |
| ------- | ------ | ------------------------------- |
| `email` | string | **Required.** Address to check. |

## Response Fields

| Field        | Type                   | Description                                                                                                     |
| ------------ | ---------------------- | --------------------------------------------------------------------------------------------------------------- |
| `object`     | `"email_verification"` | Resource type.                                                                                                  |
| `id`         | string                 | Tenant-owned `verify_result` id.                                                                                |
| `email`      | string                 | Normalized address that was checked.                                                                            |
| `result`     | string                 | `deliverable`, `risky`, `undeliverable`, or `unknown`.                                                          |
| `accept`     | boolean                | Policy decision. Can be `true` when quota is exhausted.                                                         |
| `status`     | string                 | `completed`, `skipped`, or `failed`.                                                                            |
| `score`      | integer \| null        | 0–100 deliverability confidence.                                                                                |
| `suggestion` | string \| null         | Better address when the domain looks like a typo.                                                               |
| `traits`     | object                 | Stable booleans for disposable, free-provider, privacy-alias, role-based, and suspicious-domain classification. |
| `issues`     | object[]               | Errors and warnings only. Empty when the address is clean. Primary reason is first.                             |
| `quota`      | object                 | `{ limit, used, remaining, resets_at }` for the UTC month.                                                      |
| `retry_at`   | string \| null         | ISO timestamp when a retryable check should be tried again.                                                     |
| `cache_hit`  | boolean                | `true` when the result was served from cache.                                                                   |

### `issues[]`

| Field       | Type                     | Description                                                                       |
| ----------- | ------------------------ | --------------------------------------------------------------------------------- |
| `code`      | string                   | Stable reason (`mailbox_not_found`, `domain_typo`, `catch_all`, …).               |
| `message`   | string                   | Human-readable reason.                                                            |
| `severity`  | `"error"` \| `"warning"` | `error` blocks `accept`. `warning` does not.                                      |
| `retryable` | boolean                  | `true` for `mailbox_full`, `mailbox_unknown`, `quota_exceeded`, `internal_error`. |

Common `code` values: `invalid_format`, `disposable_email`, `spam_domain`, `invalid_domain`, `test_email`, `mailbox_not_found`, `mailbox_full`, `mailbox_unknown`, `previously_bounced`, `previously_complained`, `globally_suppressed`, `quota_exceeded`, `internal_error`, `domain_typo`, `role_based`, `spam_like`, `privacy_alias`, `suspicious_domain`, `catch_all`.

## Errors

| Status | `name`               | When                                |
| ------ | -------------------- | ----------------------------------- |
| 401    | `missing_api_key`    | Missing or invalid bearer token     |
| 403    | `missing_permission` | Token lacks the `emails` permission |
| 422    | `validation_error`   | Missing `email`                     |

## Related

- [SDK: lumail.verify](/docs/sdk/v2/verify)

_Older emails.verify path: [emails.verify](/docs/api-reference/v2/emails-verify)._


## API Reference
**Method:** POST
**Endpoint:** /api/v2/verify

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.verify("user@example.com");
if (error) {
  throw error;
}
if (!data.accept) {
  throw data.issues[0];
}
console.log(data.result, data.score, data.suggestion);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/verify \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com"}'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/verify", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ email: "user@example.com" }),
});
const data = await response.json();
```

### Success Response
```json
{
  "object": "email_verification",
  "id": "vr_abc123",
  "email": "user@example.com",
  "result": "risky",
  "accept": true,
  "status": "completed",
  "score": 80,
  "suggestion": null,
  "traits": {
    "domain_category": "custom_domain",
    "disposable": false,
    "free_provider": false,
    "privacy_alias": false,
    "role_based": false,
    "suspicious_domain": false
  },
  "issues": [],
  "quota": {
    "limit": 10000,
    "used": 12,
    "remaining": 9988,
    "resets_at": "2026-10-01T00:00:00.000Z"
  },
  "retry_at": null,
  "cache_hit": false
}
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "Email is required",
  "statusCode": 422
}
```

---

# Send Email
Source: https://lumail.io/docs/api-reference/v2/emails-send.md

Queues one transactional email. Permission: `emails`.

> **Test-mode recipients.** Fixture addresses such as `user@example.com` never send real email. See [Test-mode recipients](/docs/api-reference/test-mode).
>
> **Recipient rate guards.** The same real mailbox is capped at 5 sends / 10 minutes and 20 / 24 hours. A 429 with `code: RECIPIENT_RATE_LIMITED` is not the HTTP API rpm limit. See [Recipient rate guards](/docs/deliverability/recipient-rate-guards).

Exactly one of `html`, `tiptap`, or `markdown`. Markdown is rendered to HTML. Omit `text` to generate it from the rendered HTML. `text: ""` skips generation.

Unsupported: `attachments`, `template`, `topic_id`, `scheduled_at`, `react`, `content`, `contentType`.

## Body Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `from` | string | **Required.** Sender. Display name is allowed: `Acme <hello@yourdomain.com>`. Domain must be verified. |
| `to` | string or `[string]` | **Required.** One recipient. A one-address array is accepted. |
| `subject` | string | **Required.** Subject line. |
| `html` | string | HTML body. Provide exactly one of `html`, `tiptap`, or `markdown`. |
| `tiptap` | string or object | TipTap JSON. Rendered to HTML on send. |
| `markdown` | string | Markdown body. Rendered to HTML on send. |
| `text` | string | Plain-text body. Omit to generate from HTML. Empty string skips generation. |
| `reply_to` | string or string[] | Reply-To addresses. Stored as an array. SES uses the first address. |
| `cc` | string or string[] | Carbon copies. |
| `bcc` | string or string[] | Blind carbon copies. |
| `headers` | object | Extra MIME headers. |
| `tags` | `{ name, value }[]` | Metadata tags stored on the email. |
| `preview` | string | Preview / preheader text. |
| `tracking` | `{ links: boolean, open: boolean }` | Open pixel and click wrapping. Both default to `true`. Set both to `false` for OTP / magic-link mail. |

## Options

| Name | Type | Description |
| ---- | ---- | ----------- |
| `Idempotency-Key` | string | Optional header. Repeat the same key to reuse the queued email id. |

SDK: pass `{ idempotencyKey }` as the second argument to `emails.send`.

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | string | Queued email id (`eml_...`). Fetch the body and `last_event` with [Get Email](/docs/api-reference/v2/emails-get). |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the `emails` permission |
| 422 | `validation_error` | Missing required fields, more than one body format, or unsupported fields |
| 429 | `RECIPIENT_RATE_LIMITED` | This mailbox already hit 5/10 min or 20/24 h, or is on a bounce cooldown. Honor `Retry-After`. [Recipient rate guards](/docs/deliverability/recipient-rate-guards). |

## Related

- [Get Email](/docs/api-reference/v2/emails-get)
- [List Emails](/docs/api-reference/v2/emails-list)
- [SDK: lumail.emails.send](/docs/sdk/v2/emails-send)
- [Recipient rate guards](/docs/deliverability/recipient-rate-guards)


## API Reference
**Method:** POST
**Endpoint:** /api/v2/emails

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.emails.send({
  from: "hello@yourdomain.com",
  to: "user@example.com",
  subject: "Welcome",
  html: "<p>Hello</p>",
});
if (error) {
  throw error;
}
console.log(data.id);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/emails \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "hello@yourdomain.com",
    "to": "user@example.com",
    "subject": "Welcome",
    "html": "<p>Hello</p>"
  }'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/emails", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    from: "hello@yourdomain.com",
    to: "user@example.com",
    subject: "Welcome",
    html: "<p>Hello</p>",
  }),
});
const data = await response.json();
```

### Success Response
```json
{ "id": "eml_abc123def456" }
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "Provide exactly one of html, tiptap, or markdown",
  "statusCode": 422
}
```

---

# Batch Emails
Source: https://lumail.io/docs/api-reference/v2/emails-batch.md

Sends up to 100 transactional emails. Body is a JSON array of the same objects as [Send Email](/docs/api-reference/v2/emails-send). Permission: `emails`.

> **Test-mode recipients.** Fixture addresses such as `user@example.com` never send real email. See [Test-mode recipients](/docs/api-reference/test-mode).
>
> **Recipient rate guards.** Each real `to` shares the 5/10 min and 20/24 h caps. A 429 with `code: RECIPIENT_RATE_LIMITED` is not the HTTP API rpm limit. See [Recipient rate guards](/docs/deliverability/recipient-rate-guards).

## Body Parameters

Each array item:

| Name | Type | Description |
| ---- | ---- | ----------- |
| `from` | string | **Required.** Sender. Display name is allowed. Domain must be verified. |
| `to` | string or `[string]` | **Required.** One recipient. A one-address array is accepted. |
| `subject` | string | **Required.** Subject line. |
| `html` | string | HTML body. Provide exactly one of `html`, `tiptap`, or `markdown`. |
| `tiptap` | string or object | TipTap JSON. Rendered to HTML on send. |
| `markdown` | string | Markdown body. Rendered to HTML on send. |
| `text` | string | Plain-text body. Omit to generate from HTML. |
| `reply_to` | string or string[] | Reply-To addresses. |
| `cc` | string or string[] | Carbon copies. |
| `bcc` | string or string[] | Blind carbon copies. |
| `headers` | object | Extra MIME headers. |
| `tags` | `{ name, value }[]` | Metadata tags stored on the email. |
| `preview` | string | Preview / preheader text. |

## Options

| Name | Type | Description |
| ---- | ---- | ----------- |
| `Idempotency-Key` | string | Optional header. Suffixed with `:index` per item. SDK: pass `{ idempotencyKey }` as the second argument to `emails.batch`. |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `data` | `{ id }[]` | Queued email ids, same order as the request. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the `emails` permission |
| 422 | `validation_error` | Body is not an array, or an item fails send validation |
| 429 | `RECIPIENT_RATE_LIMITED` | A real `to` already hit 5/10 min or 20/24 h. [Recipient rate guards](/docs/deliverability/recipient-rate-guards). |

## Related

- [Send Email](/docs/api-reference/v2/emails-send)
- [Get Email](/docs/api-reference/v2/emails-get)
- [SDK: lumail.emails.batch](/docs/sdk/v2/emails-batch)
- [Recipient rate guards](/docs/deliverability/recipient-rate-guards)


## API Reference
**Method:** POST
**Endpoint:** /api/v2/emails/batch

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.emails.batch([
  {
    from: "hello@yourdomain.com",
    to: "a@example.com",
    subject: "A",
    html: "<p>A</p>",
  },
  {
    from: "hello@yourdomain.com",
    to: "b@example.com",
    subject: "B",
    html: "<p>B</p>",
  },
]);
if (error) {
  throw error;
}
console.log(data.data.map((email) => email.id));
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/emails/batch \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '[
    {"from":"hello@yourdomain.com","to":"a@example.com","subject":"A","html":"<p>A</p>"},
    {"from":"hello@yourdomain.com","to":"b@example.com","subject":"B","html":"<p>B</p>"}
  ]'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/emails/batch", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify([
    {
      from: "hello@yourdomain.com",
      to: "a@example.com",
      subject: "A",
      html: "<p>A</p>",
    },
    {
      from: "hello@yourdomain.com",
      to: "b@example.com",
      subject: "B",
      html: "<p>B</p>",
    },
  ]),
});
const data = await response.json();
```

### Success Response
```json
{
  "data": [
    { "id": "eml_abc123def456" },
    { "id": "eml_def456abc123" }
  ]
}
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "Batch payload must be an array of emails",
  "statusCode": 422
}
```

---

# Get Email
Source: https://lumail.io/docs/api-reference/v2/emails-get.md

Returns one email in your organization. Use the `id` from [Send Email](/docs/api-reference/v2/emails-send) or [List Emails](/docs/api-reference/v2/emails-list). Permission: `emails`.

Call this after send when you need the body, tags, or the current `last_event`. List does not return `html`, `text`, or `tags`.

## Path Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `id` | string | **Required.** Email id (`eml_...`). |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `object` | `"email"` | Discriminator. Always `email` on this endpoint. |
| `id` | string | Lumail email id. |
| `message_id` | string or null | Provider message id (SES) once accepted. `null` while still queued. |
| `from` | string | Envelope From, including display name when you sent one. |
| `to` | string[] | Recipients. v2 send accepts one address. |
| `subject` | string | Subject line. |
| `html` | string or null | Rendered HTML body. |
| `text` | string or null | Plain-text body. Generated from HTML on send when you omit `text`. |
| `cc` | string[] or null | Carbon copies from send, otherwise `null`. |
| `bcc` | string[] or null | Blind carbon copies from send, otherwise `null`. |
| `reply_to` | string[] or null | Reply-To addresses from send, otherwise `null`. |
| `scheduled_at` | null | Always `null`. Scheduled send is not supported on v2 transactional emails. |
| `tags` | `{ name, value }[]` | Tags from send. Empty array when none. |
| `created_at` | string | ISO 8601 timestamp. |
| `last_event` | string | Latest delivery or tracking event. See below. |

## Last event

`last_event` is derived, not stored as a separate field. Tracking wins over delivery:

1. `clicked` if the recipient clicked a link
2. `opened` if the recipient opened the email
3. otherwise the delivery status

| Value | Meaning |
| ----- | ------- |
| `queued` | Accepted by Lumail, not yet handed to SES (`PENDING`) |
| `sent` | Handed to SES (`SENT`), or delivery status is still `null` |
| `delivered` | SES reported delivery |
| `opened` | At least one unique open |
| `clicked` | At least one unique click |
| `bounced` | SES bounce |
| `complained` | Spam complaint |
| `failed` | Send failed |

## Body content

`html` and `text` are the stored rendered bodies. `tiptap` is send-only: if you queued TipTap JSON, this response still returns HTML, never the TipTap document.

Campaign emails and test sends are retrievable by id. [List Emails](/docs/api-reference/v2/emails-list) only returns transactional API rows (`campaignId` null, `isTest` false).

## Errors

Email routes use `{ name, message, statusCode }`.

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid `Authorization: Bearer lum_...` |
| 403 | `validation_error` | Token lacks the `emails` permission |
| 404 | `not_found` | Unknown id, or the email belongs to another organization |

## Related

- [Send Email](/docs/api-reference/v2/emails-send)
- [List Emails](/docs/api-reference/v2/emails-list)
- [SDK: lumail.emails.get](/docs/sdk/v2/emails-get)


## API Reference
**Method:** GET
**Endpoint:** /api/v2/emails/{id}

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data: email, error } = await lumail.emails.get(
  "eml_abc123def456",
);
if (error) {
  throw error;
}

console.log(email.last_event);
console.log(email.html);
```

### cURL
```bash
curl https://lumail.io/api/v2/emails/eml_abc123def456 \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch(
  "https://lumail.io/api/v2/emails/eml_abc123def456",
  {
    headers: {
      Authorization: "Bearer YOUR_API_TOKEN",
    },
  },
);

if (!response.ok) {
  const error = await response.json();
  throw new Error(`${error.name}: ${error.message}`);
}

const email = await response.json();
console.log(email.last_event);
```

### Success Response
```json
{
  "object": "email",
  "id": "eml_abc123def456",
  "message_id": "0100018f3c2a1b2c-d4e5f6a7-89ab-cdef-0123-456789abcdef-000000",
  "to": ["user@example.com"],
  "from": "hello@yourdomain.com",
  "created_at": "2026-09-05T10:30:00.000Z",
  "subject": "Welcome",
  "html": "<p>Hello</p>",
  "text": "Hello",
  "last_event": "delivered",
  "tags": []
}
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Email not found",
  "statusCode": 404
}
```

---

# List Emails
Source: https://lumail.io/docs/api-reference/v2/emails-list.md

Lists transactional emails created through the API, newest first. Campaign sends and test emails are excluded (`campaignId` null, `isTest` false). Permission: `emails`.

List rows are metadata plus `last_event`. They do not include `html`, `text`, or `tags`. Fetch the body with [Get Email](/docs/api-reference/v2/emails-get).

## Query Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `limit` | integer | Page size. Default `20`. Min `1`, max `100`. |
| `after` | string | Return emails older than this id (next page, walking backward in time). |
| `before` | string | Return emails newer than this id. |

Pass `after` or `before`, not both. The cursor must be an email id that exists in this organization.

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `object` | `"list"` | Discriminator. Always `list`. |
| `has_more` | boolean | `true` when another page exists in the same direction. |
| `data` | array | Email objects, newest first. Same fields as Get Email except `object`, `html`, `text`, and `tags`. |

`last_event` uses the same mapping as [Get Email](/docs/api-reference/v2/emails-get#last-event).

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `validation_error` | Token lacks the `emails` permission |
| 422 | `validation_error` | Both `after` and `before`, or cursor id not found |

## Related

- [Send Email](/docs/api-reference/v2/emails-send)
- [Get Email](/docs/api-reference/v2/emails-get)


## API Reference
**Method:** GET
**Endpoint:** /api/v2/emails

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data: page, error } = await lumail.emails.list({ limit: 20 });
if (error) {
  throw error;
}

for (const email of page.data) {
  console.log(email.id, email.last_event);
}

if (page.has_more) {
  const next = await lumail.emails.list({
    limit: 20,
    after: page.data.at(-1)?.id,
  });
  console.log(next.data?.data.length);
}
```

### cURL
```bash
curl "https://lumail.io/api/v2/emails?limit=20" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

curl "https://lumail.io/api/v2/emails?limit=20&after=eml_abc123def456" \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/emails?limit=20", {
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
  },
});
const page = await response.json();

const next = await fetch(
  `https://lumail.io/api/v2/emails?limit=20&after=${page.data.at(-1).id}`,
  { headers: { Authorization: "Bearer YOUR_API_TOKEN" } },
);
await next.json();
```

### Success Response
```json
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "eml_abc123def456",
      "message_id": "0100018f3c2a1b2c-d4e5f6a7-89ab-cdef-0123-456789abcdef-000000",
      "to": ["user@example.com"],
      "from": "hello@yourdomain.com",
      "created_at": "2026-09-05T10:30:00.000Z",
      "subject": "Welcome",
      "last_event": "delivered"
    }
  ]
}
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "Use either after or before, not both",
  "statusCode": 422
}
```

---

# Subscribers
Source: https://lumail.io/docs/api-reference/v2/subscribers.md

Thin adapters over subscriber tools.

`:subscriber` is an id or email. Delete stays disabled, same policy as v1.

---

# List Subscribers
Source: https://lumail.io/docs/api-reference/v2/subscribers-list.md

Lists contacts newest first. Permission: `subscribers`.

## Query Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `limit` | integer | Page size. Default `50`. Min `1`, max `100`. |
| `after` | string | Subscriber id cursor. Next page, older than this id. |
| `status` | string | Filter: `SUBSCRIBED`, `PENDING_CONFIRMATION`, `UNSUBSCRIBED`, `BOUNCED`, `BANNED`, `COMPLAINED`, `TRANSACTIONAL`, or `all`. |
| `tag` | string | Filter by tag name (case-insensitive). |
| `query` | string | Search email or name. |

`after` is a subscriber id from the previous page.

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `object` | `"list"` | Discriminator. Always `list`. |
| `has_more` | boolean | `true` when another page exists. |
| `data` | array | Subscriber rows: `id`, `email`, `name`, `status`, `createdAt`, `tags`. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the `subscribers` permission |

## Related

- [Create Subscriber](/docs/api-reference/v2/subscribers-create)
- [Get Subscriber](/docs/api-reference/v2/subscribers-get)
- [SDK: lumail.subscribers.list](/docs/sdk/v2/subscribers-list)


## API Reference
**Method:** GET
**Endpoint:** /api/v2/subscribers

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.subscribers.list({
  limit: 20,
  status: "SUBSCRIBED",
});
if (error) {
  throw error;
}

for (const subscriber of data.data) {
  console.log(subscriber.id, subscriber.email);
}

if (data.has_more) {
  const next = await lumail.subscribers.list({
    limit: 20,
    after: data.data.at(-1)?.id,
  });
  console.log(next.data?.data.length);
}
```

### cURL
```bash
curl "https://lumail.io/api/v2/subscribers?limit=20&status=SUBSCRIBED" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

curl "https://lumail.io/api/v2/subscribers?limit=20&after=sub_abc123" \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch(
  "https://lumail.io/api/v2/subscribers?limit=20&status=SUBSCRIBED",
  {
    headers: {
      Authorization: "Bearer YOUR_API_TOKEN",
    },
  },
);
const page = await response.json();

const next = await fetch(
  `https://lumail.io/api/v2/subscribers?limit=20&after=${page.data.at(-1).id}`,
  { headers: { Authorization: "Bearer YOUR_API_TOKEN" } },
);
await next.json();
```

### Success Response
```json
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "sub_abc123",
      "email": "user@example.com",
      "name": "Ada",
      "status": "SUBSCRIBED",
      "createdAt": "2026-09-05T10:30:00.000Z",
      "tags": [{ "id": "tag_abc123", "name": "newsletter" }]
    }
  ]
}
```

### Error Response
```json
{
  "name": "missing_api_key",
  "message": "Missing API key",
  "statusCode": 401
}
```

---

# Create Subscriber
Source: https://lumail.io/docs/api-reference/v2/subscribers-create.md

Create or upsert a contact by email. Permission: `subscribers`.

`skipDoubleOptIn` defaults to `false`. Pass `true` from a trusted backend to create the contact as `SUBSCRIBED` even when organization double opt-in is on. This does not write confirmation evidence and does not resubscribe `UNSUBSCRIBED` unless `resubscribe` is also true.

## Body Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `email` | string | **Required.** Contact email. Upserts if it already exists. |
| `name` | string or null | Display name. |
| `phone` | string or null | Phone number. |
| `tags` | string[] | Tag names to attach. Created if missing. |
| `fields` | `Record<string, string \| null>` | Custom field values. |
| `replaceTags` | boolean | When `true`, replace existing tags with `tags`. Default `false`. |
| `resubscribe` | boolean | Re-subscribe an `UNSUBSCRIBED` contact. Default `true` on create. |
| `triggerWorkflows` | boolean | Fire subscribe / tag workflows. |
| `skipDoubleOptIn` | boolean | Create as `SUBSCRIBED` even when org double opt-in is on. Default `false`. |
| `ipAddress` | string | Client IP stored as signup evidence. |
| `country` | string | ISO 3166-1 alpha-2 country code. |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | string | Subscriber id. |
| `email` | string | Email address. |
| `name` | string or null | Display name. |
| `phone` | string or null | Phone number. |
| `status` | string | `PENDING_CONFIRMATION`, `SUBSCRIBED`, `UNSUBSCRIBED`, `BOUNCED`, `BANNED`, `COMPLAINED`, or `TRANSACTIONAL`. |
| `tags` | `{ id, name }[]` | Current audience tags. |
| `fields` | object | Custom fields. |
| `warnings` | string[] | Present when some tags or fields could not be applied. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the `subscribers` permission |
| 422 | `validation_error` | Invalid email or payload |

## Related

- [WordPress Plugin](/docs/integrations/wordpress) — `[lumail_form]` uses this endpoint
- [Get Subscriber](/docs/api-reference/v2/subscribers-get)
- [List Subscribers](/docs/api-reference/v2/subscribers-list)
- [SDK: lumail.subscribers.create](/docs/sdk/v2/subscribers-create)


## API Reference
**Method:** POST
**Endpoint:** /api/v2/subscribers

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.subscribers.create({
  email: "user@example.com",
  name: "Ada",
  tags: ["newsletter"],
});
if (error) {
  throw error;
}
console.log(data.id);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/subscribers \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com","name":"Ada","tags":["newsletter"]}'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/subscribers", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    email: "user@example.com",
    name: "Ada",
    tags: ["newsletter"],
  }),
});
const data = await response.json();
```

### Success Response
```json
{
  "id": "sub_abc123",
  "email": "user@example.com",
  "name": "Ada",
  "status": "SUBSCRIBED"
}
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "Invalid email",
  "statusCode": 422
}
```

---

# Get Subscriber
Source: https://lumail.io/docs/api-reference/v2/subscribers-get.md

Returns one contact. `:subscriber` is an id or email. Permission: `subscribers`.

## Path Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `subscriber` | string | **Required.** Subscriber id or email. |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | string | Subscriber id. |
| `email` | string | Email address. |
| `name` | string or null | Display name. |
| `phone` | string or null | Phone number. |
| `status` | string | Current status. |
| `createdAt` | string | ISO 8601 created timestamp. |
| `updatedAt` | string | ISO 8601 updated timestamp. |
| `tags` | `{ id, name }[]` | Attached tags. |
| `fields` | `{ name, value }[]` | Custom fields. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the `subscribers` permission |
| 404 | `not_found` | Unknown id or email |

## Related

- [Update Subscriber](/docs/api-reference/v2/subscribers-update)
- [List Subscribers](/docs/api-reference/v2/subscribers-list)
- [SDK: lumail.subscribers.get](/docs/sdk/v2/subscribers-get)


## API Reference
**Method:** GET
**Endpoint:** /api/v2/subscribers/:subscriber

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.subscribers.get("user@example.com");
if (error) {
  throw error;
}
console.log(data.id, data.status);
```

### cURL
```bash
curl https://lumail.io/api/v2/subscribers/user@example.com \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch(
  "https://lumail.io/api/v2/subscribers/user@example.com",
  {
    headers: {
      Authorization: "Bearer YOUR_API_TOKEN",
    },
  },
);
const data = await response.json();
```

### Success Response
```json
{
  "id": "sub_abc123",
  "email": "user@example.com",
  "name": "Ada",
  "phone": null,
  "status": "SUBSCRIBED",
  "createdAt": "2026-09-05T10:30:00.000Z",
  "updatedAt": "2026-09-05T10:30:00.000Z",
  "tags": [{ "id": "tag_abc123", "name": "newsletter" }],
  "fields": [{ "name": "plan", "value": "pro" }]
}
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Subscriber not found",
  "statusCode": 404
}
```

---

# Update Subscriber
Source: https://lumail.io/docs/api-reference/v2/subscribers-update.md

Patch a contact. `:subscriber` is an id or email. Delete stays disabled; use [Unsubscribe](/docs/api-reference/v2/subscribers-unsubscribe). Permission: `subscribers`.

Omitted `resubscribe` does **not** re-subscribe an unsubscribed contact. Optional `skipDoubleOptIn: true` promotes `PENDING_CONFIRMATION` to `SUBSCRIBED` before tag events.

## Path Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `subscriber` | string | **Required.** Subscriber id or email. |

## Body Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `email` | string | New email address. |
| `name` | string or null | Display name. |
| `phone` | string or null | Phone number. |
| `tags` | string[] | Tag names to attach. |
| `fields` | `Record<string, string \| null>` | Custom field values. |
| `replaceTags` | boolean | When `true`, replace existing tags with `tags`. |
| `resubscribe` | boolean | Re-subscribe an `UNSUBSCRIBED` contact. |
| `triggerWorkflows` | boolean | Fire tag / field workflows. |
| `skipDoubleOptIn` | boolean | Promote `PENDING_CONFIRMATION` to `SUBSCRIBED`. |
| `ipAddress` | string | Client IP stored as evidence. |
| `country` | string | ISO 3166-1 alpha-2 country code. |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | string | Subscriber id. |
| `email` | string | Email address. |
| `name` | string or null | Display name. |
| `phone` | string or null | Phone number. |
| `status` | string | Current status. |
| `tags` | `{ id, name }[]` | Current audience tags. |
| `fields` | object | Custom fields. |
| `warnings` | string[] | Present when some tags or fields could not be applied. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the `subscribers` permission |
| 404 | `not_found` | Unknown id or email |
| 422 | `validation_error` | Invalid payload |

## Related

- [Get Subscriber](/docs/api-reference/v2/subscribers-get)
- [Unsubscribe Subscriber](/docs/api-reference/v2/subscribers-unsubscribe)
- [SDK: lumail.subscribers.update](/docs/sdk/v2/subscribers-update)


## API Reference
**Method:** PATCH
**Endpoint:** /api/v2/subscribers/:subscriber

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.subscribers.update("user@example.com", {
  name: "Ada Lovelace",
});
if (error) {
  throw error;
}
console.log(data.name);
```

### cURL
```bash
curl -X PATCH https://lumail.io/api/v2/subscribers/user@example.com \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Ada Lovelace"}'
```

### JavaScript
```javascript
const response = await fetch(
  "https://lumail.io/api/v2/subscribers/user@example.com",
  {
    method: "PATCH",
    headers: {
      Authorization: "Bearer YOUR_API_TOKEN",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ name: "Ada Lovelace" }),
  },
);
const data = await response.json();
```

### Success Response
```json
{
  "id": "sub_abc123",
  "email": "user@example.com",
  "name": "Ada Lovelace",
  "status": "SUBSCRIBED"
}
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Subscriber not found",
  "statusCode": 404
}
```

---

# Unsubscribe Subscriber
Source: https://lumail.io/docs/api-reference/v2/subscribers-unsubscribe.md

Marks a contact unsubscribed. `:subscriber` is an id or email. Subscriber DELETE is disabled; this is the supported removal path. Permission: `subscribers`.

Idempotent: repeating the call on an already unsubscribed contact still returns 200.

## Path Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `subscriber` | string | **Required.** Subscriber id or email. |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `message` | string | Confirmation text. |
| `id` | string | Subscriber id when present. |
| `email` | string | Email when present. |
| `status` | string | `UNSUBSCRIBED` when present. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the `subscribers` permission |
| 404 | `not_found` | Unknown id or email |

## Related

- [Get Subscriber](/docs/api-reference/v2/subscribers-get)
- [SDK: lumail.subscribers.unsubscribe](/docs/sdk/v2/subscribers-unsubscribe)


## API Reference
**Method:** POST
**Endpoint:** /api/v2/subscribers/:subscriber/unsubscribe

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.subscribers.unsubscribe(
  "user@example.com",
);
if (error) {
  throw error;
}
console.log(data.message);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/subscribers/user@example.com/unsubscribe \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch(
  "https://lumail.io/api/v2/subscribers/user@example.com/unsubscribe",
  {
    method: "POST",
    headers: {
      Authorization: "Bearer YOUR_API_TOKEN",
    },
  },
);
const data = await response.json();
```

### Success Response
```json
{
  "message": "Subscriber unsubscribed successfully"
}
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Subscriber not found",
  "statusCode": 404
}
```

---

# Add Subscriber Tags
Source: https://lumail.io/docs/api-reference/v2/subscribers-tags-add.md

Attaches tags to a contact. Missing tag names are created. Emits `tag.added` events. Permission: `subscribers`.

## Path Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `subscriber` | string | **Required.** Subscriber id or email. |

## Body Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `tags` | string[] | **Required.** Tag names to attach. |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `tagsAdded` | integer | Number of tags processed. |
| `subscribersProcessed` | integer | Contacts updated (always `1` on this route). |
| `totalAssociationsCreated` | integer | New subscriber–tag links created. |
| `warning` | string | Present when some ids were skipped. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the `subscribers` permission |
| 404 | `not_found` | Unknown subscriber |
| 422 | `validation_error` | Invalid JSON body |

## Related

- [Remove Subscriber Tags](/docs/api-reference/v2/subscribers-tags-remove)
- [SDK: lumail.subscribers.addTags](/docs/sdk/v2/subscribers-add-tags)


## API Reference
**Method:** POST
**Endpoint:** /api/v2/subscribers/:subscriber/tags

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.subscribers.addTags(
  "user@example.com",
  ["customer"],
);
if (error) {
  throw error;
}
console.log(data.tagsAdded);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/subscribers/user@example.com/tags \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"tags":["customer"]}'
```

### JavaScript
```javascript
const response = await fetch(
  "https://lumail.io/api/v2/subscribers/user@example.com/tags",
  {
    method: "POST",
    headers: {
      Authorization: "Bearer YOUR_API_TOKEN",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ tags: ["customer"] }),
  },
);
const data = await response.json();
```

### Success Response
```json
{
  "tagsAdded": 1,
  "subscribersProcessed": 1,
  "totalAssociationsCreated": 1
}
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Subscriber not found",
  "statusCode": 404
}
```

---

# Remove Subscriber Tags
Source: https://lumail.io/docs/api-reference/v2/subscribers-tags-remove.md

Detaches tags from a contact. Emits `tag.removed` events. Permission: `subscribers`.

Pass `tags` as a JSON array in the query string.

## Path Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `subscriber` | string | **Required.** Subscriber id or email. |

## Query Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `tags` | string | **Required.** JSON array of tag names, e.g. `["customer"]`. |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `removedCount` | integer | Associations removed. |
| `message` | string | Optional status text. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the `subscribers` permission |
| 404 | `not_found` | Unknown subscriber |
| 422 | `validation_error` | `tags` is not a JSON array of strings |

## Related

- [Add Subscriber Tags](/docs/api-reference/v2/subscribers-tags-add)
- [SDK: lumail.subscribers.removeTags](/docs/sdk/v2/subscribers-remove-tags)


## API Reference
**Method:** DELETE
**Endpoint:** /api/v2/subscribers/:subscriber/tags

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.subscribers.removeTags(
  "user@example.com",
  ["customer"],
);
if (error) {
  throw error;
}
console.log(data.removedCount);
```

### cURL
```bash
curl -X DELETE "https://lumail.io/api/v2/subscribers/user@example.com/tags?tags=%5B%22customer%22%5D" \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const tags = encodeURIComponent(JSON.stringify(["customer"]));
const response = await fetch(
  `https://lumail.io/api/v2/subscribers/user@example.com/tags?tags=${tags}`,
  {
    method: "DELETE",
    headers: {
      Authorization: "Bearer YOUR_API_TOKEN",
    },
  },
);
const data = await response.json();
```

### Success Response
```json
{
  "removedCount": 1
}
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "tags must be a JSON array of strings",
  "statusCode": 422
}
```

---

# List Subscriber Events
Source: https://lumail.io/docs/api-reference/v2/subscribers-events.md

Lists events recorded on one contact, newest first. Permission: `subscribers`.

## Path Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `subscriber` | string | **Required.** Subscriber id or email. |

## Query Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `limit` | integer | Page size. Default `50`. Min `1`, max `100`. |
| `after` | string | Event id cursor. Next page, older than this id. |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `object` | `"list"` | Discriminator. Always `list`. |
| `has_more` | boolean | `true` when another page exists. |
| `data` | array | Event objects: `id`, `eventType`, `data`, `createdAt`. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the `subscribers` permission |
| 404 | `not_found` | Unknown subscriber |

## Related

- [Create Event](/docs/api-reference/v2/events)
- [Get Subscriber](/docs/api-reference/v2/subscribers-get)
- [SDK: lumail.subscribers.listEvents](/docs/sdk/v2/subscribers-list-events)


## API Reference
**Method:** GET
**Endpoint:** /api/v2/subscribers/:subscriber/events

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.subscribers.listEvents(
  "user@example.com",
  { limit: 20 },
);
if (error) {
  throw error;
}

for (const event of data.data) {
  console.log(event.id, event.eventType);
}
```

### cURL
```bash
curl "https://lumail.io/api/v2/subscribers/user@example.com/events?limit=20" \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch(
  "https://lumail.io/api/v2/subscribers/user@example.com/events?limit=20",
  {
    headers: {
      Authorization: "Bearer YOUR_API_TOKEN",
    },
  },
);
const page = await response.json();
```

### Success Response
```json
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "evt_abc123",
      "eventType": "SUBSCRIBER_PAYMENT",
      "data": { "amount": 5000, "product": "Course" },
      "createdAt": "2026-09-05T10:30:00.000Z"
    }
  ]
}
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Subscriber not found",
  "statusCode": 404
}
```

---

# Domains
Source: https://lumail.io/docs/api-reference/v2/domains.md

`:domain` is a domain id or hostname. Permission: `domains`.

---

# List Domains
Source: https://lumail.io/docs/api-reference/v2/domains-list.md

Lists sending domains. Permission: `domains`.

SDK `domains.list()` takes no args. HTTP accepts optional `?limit=` to slice the list.

## Query Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `limit` | integer | Optional slice. When the org has more domains than `limit`, `has_more` is `true`. |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `object` | `"list"` | Discriminator. Always `list`. |
| `has_more` | boolean | `true` when `limit` truncated the list. |
| `data` | array | Domain objects: `id`, `domain`, `status`, `region`, `createdAt`, `emailSendersCount`. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the `domains` permission |

## Related

- [Create Domain](/docs/api-reference/v2/domains-create)
- [Get Domain](/docs/api-reference/v2/domains-get)
- [SDK: lumail.domains.list](/docs/sdk/v2/domains-list)


## API Reference
**Method:** GET
**Endpoint:** /api/v2/domains

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.domains.list();
if (error) {
  throw error;
}

for (const domain of data.data) {
  console.log(domain.id, domain.domain, domain.status);
}
```

### cURL
```bash
curl "https://lumail.io/api/v2/domains?limit=20" \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/domains?limit=20", {
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
  },
});
const page = await response.json();
```

### Success Response
```json
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "dom_abc123",
      "domain": "mail.example.com",
      "status": "VERIFIED",
      "region": "eu-west-1",
      "createdAt": "2026-09-05T10:30:00.000Z",
      "emailSendersCount": 1
    }
  ]
}
```

### Error Response
```json
{
  "name": "missing_permission",
  "message": "Missing permission: domains",
  "statusCode": 403
}
```

---

# Create Domain
Source: https://lumail.io/docs/api-reference/v2/domains-create.md

Registers a sending domain and returns DNS records. Permission: `domains`.

## Body Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `domain` | string | **Required.** Hostname to register, e.g. `mail.example.com`. |
| `region` | string | Optional. SES sending region: `EU_CENTRAL_1` (Frankfurt, default), `AP_SOUTHEAST_2` (Sydney) or `US_WEST_1` (N. California). Cannot be changed after creation. |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | string | MailDomain id. |
| `domain` | string | Registered hostname. |
| `status` | string | `NOT_STARTED`, `PENDING`, `VERIFIED`, or `FAILED`. |
| `region` | string | AWS region when provisioned. |
| `dnsRecords` | array | Records to add: `type`, `name`, `value`, `priority`, `ttl`, `purpose`. |
| `sesRecords` | array | Stored SES DNS records when present. |
| `message` | string | Next-step copy. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the `domains` permission |
| 400 | `validation_error` | Already registered, claimed elsewhere, plan limit, or region not available |

## Related

- [Verify Domain](/docs/api-reference/v2/domains-verify)
- [Get Domain](/docs/api-reference/v2/domains-get)
- [SDK: lumail.domains.create](/docs/sdk/v2/domains-create)


## API Reference
**Method:** POST
**Endpoint:** /api/v2/domains

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.domains.create({
  domain: "mail.example.com",
});
if (error) {
  throw error;
}
console.log(data.id, data.status);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/domains \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"domain":"mail.example.com"}'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/domains", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ domain: "mail.example.com" }),
});
const data = await response.json();
```

### Success Response
```json
{
  "id": "dom_abc123",
  "domain": "mail.example.com",
  "status": "NOT_STARTED",
  "dnsRecords": [
    {
      "type": "TXT",
      "name": "mail._domainkey.mail.example.com",
      "value": "p=...",
      "purpose": "dkim"
    }
  ],
  "message": "Domain created. Add the DNS records to your DNS provider, then call verify_domain to check verification."
}
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "This domain is already registered",
  "statusCode": 400
}
```

---

# Get Domain
Source: https://lumail.io/docs/api-reference/v2/domains-get.md

Returns one sending domain. `:domain` is a MailDomain id or hostname. The body is the domain object — not the hostname string. Permission: `domains`.

## Path Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `domain` | string | **Required.** MailDomain id or hostname. |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | string | MailDomain id. |
| `domain` | string | Hostname. |
| `status` | string | `NOT_STARTED`, `PENDING`, `VERIFIED`, or `FAILED`. |
| `region` | string | AWS region. |
| `createdAt` | string | ISO 8601 created timestamp. |
| `sesRecords` | array | Stored SES DNS records when present. |
| `dnsRecords` | array | Current DNS records: `type`, `name`, `value`, `priority`, `ttl`, `purpose`. |
| `message` | string | Status copy when verification is incomplete. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the `domains` permission |
| 404 | `not_found` | Unknown id or hostname |

## Related

- [Verify Domain](/docs/api-reference/v2/domains-verify)
- [List Domains](/docs/api-reference/v2/domains-list)
- [SDK: lumail.domains.get](/docs/sdk/v2/domains-get)


## API Reference
**Method:** GET
**Endpoint:** /api/v2/domains/:domain

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.domains.get("mail.example.com");
if (error) {
  throw error;
}
console.log(data.id, data.domain, data.status);
```

### cURL
```bash
curl https://lumail.io/api/v2/domains/mail.example.com \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch(
  "https://lumail.io/api/v2/domains/mail.example.com",
  {
    headers: {
      Authorization: "Bearer YOUR_API_TOKEN",
    },
  },
);
const data = await response.json();
```

### Success Response
```json
{
  "id": "dom_abc123",
  "domain": "mail.example.com",
  "status": "VERIFIED",
  "region": "eu-west-1",
  "createdAt": "2026-09-05T10:30:00.000Z",
  "sesRecords": []
}
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Domain not found",
  "statusCode": 404
}
```

---

# Verify Domain
Source: https://lumail.io/docs/api-reference/v2/domains-verify.md

Re-checks DNS and updates verification status. `:domain` is a MailDomain id or hostname. Permission: `domains`.

## Path Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `domain` | string | **Required.** MailDomain id or hostname. |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `status` | string | Lumail domain status after the check. |
| `isVerified` | boolean | `true` when SES identity is verified. |
| `verificationStatus` | string | SES identity verification status. |
| `dkimVerificationStatus` | string | DKIM status. |
| `mailFromStatus` | string | MAIL FROM status. |
| `dnsRecords` | array | Per-record DNS status. |
| `id` | string | MailDomain id when present. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the `domains` permission |
| 404 | `not_found` | Unknown id or hostname |

## Related

- [Get Domain](/docs/api-reference/v2/domains-get)
- [SDK: lumail.domains.verify](/docs/sdk/v2/domains-verify)


## API Reference
**Method:** POST
**Endpoint:** /api/v2/domains/:domain/verify

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.domains.verify("mail.example.com");
if (error) {
  throw error;
}
console.log(data.status);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/domains/mail.example.com/verify \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch(
  "https://lumail.io/api/v2/domains/mail.example.com/verify",
  {
    method: "POST",
    headers: {
      Authorization: "Bearer YOUR_API_TOKEN",
    },
  },
);
const data = await response.json();
```

### Success Response
```json
{
  "status": "VERIFIED",
  "isVerified": true,
  "verificationStatus": "Success",
  "dkimVerificationStatus": "Success",
  "mailFromStatus": "Success",
  "dnsRecords": []
}
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Domain not found",
  "statusCode": 404
}
```

---

# Campaigns
Source: https://lumail.io/docs/api-reference/v2/campaigns.md

Send maps to `schedule_campaign`. No date sends immediately.

---

# List Campaigns
Source: https://lumail.io/docs/api-reference/v2/campaigns-list.md

Lists campaigns newest first. Permission: `campaigns`.

## Query Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `limit` | integer | Page size. |
| `after` | string | Campaign id cursor. Next page after this id. |
| `status` | string | Filter: `DRAFT`, `ARCHIVED`, `SCHEDULED`, `SENDING`, `SENT`, `FAILED`, or `all`. |
| `query` | string | Search name or subject. |

`after` is a campaign id from the previous page. `before` is rejected.

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `object` | `"list"` | Discriminator. Always `list`. |
| `has_more` | boolean | `true` when another page exists. |
| `data` | array | Campaign list rows. |

Each row: `id`, `name`, `subject`, `status`, `recipientCount`, `emailsSentCount`, `emailsOpenedCount`, `emailsClickedCount`, `scheduledAt`, `sendAt`.

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the `campaigns` permission |
| 422 | `validation_error` | `before` passed, or invalid query |

## Related

- [Create Campaign](/docs/api-reference/v2/campaigns-create)
- [Get Campaign](/docs/api-reference/v2/campaigns-get)
- [SDK: lumail.campaigns.list](/docs/sdk/v2/campaigns-list)


## API Reference
**Method:** GET
**Endpoint:** /api/v2/campaigns

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.campaigns.list({
  limit: 20,
  status: "DRAFT",
});
if (error) {
  throw error;
}

for (const campaign of data.data) {
  console.log(campaign.id, campaign.subject);
}

if (data.has_more) {
  const next = await lumail.campaigns.list({
    limit: 20,
    after: data.data.at(-1)?.id,
  });
  console.log(next.data?.data.length);
}
```

### cURL
```bash
curl "https://lumail.io/api/v2/campaigns?limit=20&status=DRAFT" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

curl "https://lumail.io/api/v2/campaigns?limit=20&after=cmp_abc123" \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch(
  "https://lumail.io/api/v2/campaigns?limit=20&status=DRAFT",
  {
    headers: {
      Authorization: "Bearer YOUR_API_TOKEN",
    },
  },
);
const page = await response.json();
```

### Success Response
```json
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "cmp_abc123",
      "name": "Launch",
      "subject": "Hello",
      "status": "DRAFT",
      "recipientCount": 0,
      "emailsSentCount": 0,
      "emailsOpenedCount": 0,
      "emailsClickedCount": 0,
      "scheduledAt": null,
      "sendAt": null
    }
  ]
}
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "before is not supported for campaigns; use after",
  "statusCode": 422
}
```

---

# Create Campaign
Source: https://lumail.io/docs/api-reference/v2/campaigns-create.md

Creates a draft campaign. Permission: `campaigns`.

Omitted `MAILY` `content` gets a default template with an unsubscribe link. Omitted HTML `content` gets an HTML starter that already includes `{{unsubscribeUrl}}`. Omitted `senderId` uses the default sender, then last used, then any available sender. Rendering does not add a footer.

```json
{
  "subject": "Product update",
  "contentType": "HTML",
  "content": "<html><body><table role=\"presentation\"><tr><td>Hello {{name}}</td></tr></table><p><a href=\"{{unsubscribeUrl}}\">Unsubscribe</a></p></body></html>"
}
```

## Body Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `subject` | string | **Required.** Email subject line. |
| `name` | string | Campaign name. Default `Untitled`. Max 100. |
| `preview` | string or null | Preview / preheader text. |
| `content` | unknown | MAILY: TipTap JSON. HTML: a raw HTML string that already includes `<a href="{{unsubscribeUrl}}">Unsubscribe</a>`. Rendering does not add a footer. |
| `contentType` | `"MAILY"` \| `"HTML"` | Content format. Default `MAILY`. |
| `senderId` | string | Email sender id. Defaults to the org default sender. |
| `replyTo` | string or null | Reply-To address. |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | string | Campaign id. |
| `name` | string | Campaign name. |
| `subject` | string | Subject line. |
| `preview` | string or null | Preview text. |
| `status` | string | Always `DRAFT` on create. |
| `contentType` | string | `MAILY` or `HTML`. |
| `senderId` | string | Assigned sender id. |
| `replyTo` | string or null | Reply-To address. |
| `createdAt` | string | ISO 8601 created timestamp. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the `campaigns` permission |
| 422 | `validation_error` | Missing subject or invalid payload |

## Related

- [Get Campaign](/docs/api-reference/v2/campaigns-get)
- [Send Campaign](/docs/api-reference/v2/campaigns-send)
- [SDK: lumail.campaigns.create](/docs/sdk/v2/campaigns-create)


## API Reference
**Method:** POST
**Endpoint:** /api/v2/campaigns

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.campaigns.create({
  name: "Launch",
  subject: "Hello",
});
if (error) {
  throw error;
}
console.log(data.id);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/campaigns \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Launch","subject":"Hello"}'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/campaigns", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ name: "Launch", subject: "Hello" }),
});
const data = await response.json();
```

### Success Response
```json
{
  "id": "cmp_abc123",
  "name": "Launch",
  "subject": "Hello",
  "preview": null,
  "status": "DRAFT",
  "contentType": "MAILY",
  "senderId": "snd_abc123",
  "replyTo": null,
  "createdAt": "2026-09-05T10:30:00.000Z"
}
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "Email subject line is required",
  "statusCode": 422
}
```

---

# Get Campaign
Source: https://lumail.io/docs/api-reference/v2/campaigns-get.md

Returns one campaign. Permission: `campaigns`.

## Path Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `campaignId` | string | **Required.** Campaign id. |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | string | Campaign id. |
| `name` | string | Campaign name. |
| `subject` | string | Subject line. |
| `preview` | string or null | Preview text. |
| `status` | string | `DRAFT`, `ARCHIVED`, `SCHEDULED`, `SENDING`, `SENT`, or `FAILED`. |
| `contentType` | string | `MAILY`, `PLATE`, or `MARKDOWN`. |
| `mailFormat` | string | Render format. |
| `content` | unknown | TipTap document. |
| `recipientCount` | integer | Matching recipients. |
| `recipientFilters` | array | Audience filters. |
| `emailsSentCount` | integer | Emails handed to SES. |
| `emailsOpenedCount` | integer | Unique opens. |
| `emailsClickedCount` | integer | Unique clicks. |
| `scheduledAt` | string or null | Scheduled send time. |
| `sendAt` | string or null | Actual send time. |
| `timezone` | string or null | Schedule timezone. |
| `senderId` | string | Sender id. |
| `sender` | object or null | `{ id, name, email }`. |
| `replyTo` | string or null | Reply-To address. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the `campaigns` permission |
| 404 | `not_found` | Unknown campaign id |

## Related

- [Update Campaign](/docs/api-reference/v2/campaigns-update)
- [Send Campaign](/docs/api-reference/v2/campaigns-send)
- [SDK: lumail.campaigns.get](/docs/sdk/v2/campaigns-get)


## API Reference
**Method:** GET
**Endpoint:** /api/v2/campaigns/:campaignId

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.campaigns.get("cmp_abc123");
if (error) {
  throw error;
}
console.log(data.subject, data.status);
```

### cURL
```bash
curl https://lumail.io/api/v2/campaigns/cmp_abc123 \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch(
  "https://lumail.io/api/v2/campaigns/cmp_abc123",
  {
    headers: {
      Authorization: "Bearer YOUR_API_TOKEN",
    },
  },
);
const data = await response.json();
```

### Success Response
```json
{
  "id": "cmp_abc123",
  "name": "Launch",
  "subject": "Hello",
  "preview": null,
  "status": "DRAFT",
  "contentType": "MAILY",
  "mailFormat": "HTML",
  "content": { "type": "doc", "content": [] },
  "recipientCount": 0,
  "recipientFilters": [],
  "scheduledAt": null,
  "sendAt": null,
  "senderId": "snd_abc123",
  "sender": {
    "id": "snd_abc123",
    "name": "Ada",
    "email": "hello@yourdomain.com"
  },
  "replyTo": null
}
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Campaign not found",
  "statusCode": 404
}
```

---

# Update Campaign
Source: https://lumail.io/docs/api-reference/v2/campaigns-update.md

Patches a draft campaign. Permission: `campaigns`.

## Path Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `campaignId` | string | **Required.** Campaign id. |

## Body Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `subject` | string | Email subject line. |
| `name` | string | Campaign name. Max 100. |
| `preview` | string or null | Preview / preheader text. |
| `content` | unknown | Replacement body. MAILY: TipTap JSON. HTML: raw HTML string. Switching format requires `contentType` plus matching content. |
| `contentType` | `"MAILY"` \| `"HTML"` | Target format. Metadata-only updates never switch format. |
| `senderId` | string | Email sender id. |
| `replyTo` | string or null | Reply-To address. |

All fields optional. Omit a field to leave it unchanged. A format switch requires `contentType` and a matching replacement body:

```json
{
  "id": "cmp_REPLACE_WITH_REAL_ID",
  "contentType": "HTML",
  "content": "<p>Updated email</p><p><a href=\"{{unsubscribeUrl}}\">Unsubscribe</a></p>"
}
```

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | string | Campaign id. |
| `name` | string | Campaign name. |
| `subject` | string | Subject line. |
| `preview` | string or null | Preview text. |
| `status` | string | Campaign status. |
| `contentType` | string | Content format. |
| `senderId` | string | Sender id. |
| `replyTo` | string or null | Reply-To address. |
| `updatedFields` | string[] | Fields that changed. |
| `warnings` | string[] | Present when a field was ignored or remapped. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the `campaigns` permission |
| 404 | `not_found` | Unknown campaign id |
| 422 | `validation_error` | Invalid payload or campaign is not a draft |

## Related

- [Get Campaign](/docs/api-reference/v2/campaigns-get)
- [SDK: lumail.campaigns.update](/docs/sdk/v2/campaigns-update)


## API Reference
**Method:** PATCH
**Endpoint:** /api/v2/campaigns/:campaignId

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.campaigns.update("cmp_abc123", {
  subject: "Updated subject",
});
if (error) {
  throw error;
}
console.log(data.subject);
```

### cURL
```bash
curl -X PATCH https://lumail.io/api/v2/campaigns/cmp_abc123 \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"subject":"Updated subject"}'
```

### JavaScript
```javascript
const response = await fetch(
  "https://lumail.io/api/v2/campaigns/cmp_abc123",
  {
    method: "PATCH",
    headers: {
      Authorization: "Bearer YOUR_API_TOKEN",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ subject: "Updated subject" }),
  },
);
const data = await response.json();
```

### Success Response
```json
{
  "id": "cmp_abc123",
  "name": "Launch",
  "subject": "Updated subject",
  "preview": null,
  "status": "DRAFT",
  "contentType": "MAILY",
  "senderId": "snd_abc123",
  "replyTo": null
}
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Campaign not found",
  "statusCode": 404
}
```

---

# Delete Campaign
Source: https://lumail.io/docs/api-reference/v2/campaigns-delete.md

Deletes a campaign. Permission: `campaigns`.

## Path Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `campaignId` | string | **Required.** Campaign id. |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `object` | `"campaign"` | Discriminator. |
| `id` | string | Deleted campaign id. |
| `deleted` | `true` | Always `true` on success. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the `campaigns` permission |
| 404 | `not_found` | Unknown campaign id |

## Related

- [List Campaigns](/docs/api-reference/v2/campaigns-list)
- [SDK: lumail.campaigns.delete](/docs/sdk/v2/campaigns-delete)


## API Reference
**Method:** DELETE
**Endpoint:** /api/v2/campaigns/:campaignId

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.campaigns.delete("cmp_abc123");
if (error) {
  throw error;
}
console.log(data.deleted, data.id);
```

### cURL
```bash
curl -X DELETE https://lumail.io/api/v2/campaigns/cmp_abc123 \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch(
  "https://lumail.io/api/v2/campaigns/cmp_abc123",
  {
    method: "DELETE",
    headers: {
      Authorization: "Bearer YOUR_API_TOKEN",
    },
  },
);
const data = await response.json();
```

### Success Response
```json
{
  "object": "campaign",
  "id": "cmp_abc123",
  "deleted": true
}
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Campaign not found",
  "statusCode": 404
}
```

---

# Send Campaign
Source: https://lumail.io/docs/api-reference/v2/campaigns-send.md

Schedules a draft or sends it immediately. No date sends immediately. Maps to `schedule_campaign`. Permission: `send`.

Before scheduling, Lumail runs the same JEV content verification used by the editor and public API. A blocked result includes the failed checks, badges, remediation guidance, and the [campaign content verification rules](/docs/deliverability/campaign-content-verification). Cold email and phishing are unsupported.

The campaign must be `DRAFT` and must contain an unsubscribe link.

## Path Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `campaignId` | string | **Required.** Campaign id. |

## Body Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `scheduledAt` | string | Optional ISO 8601 send time. Omit to send immediately. |
| `timezone` | string | IANA timezone for the schedule. Default `Europe/Paris`. |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | string | Campaign id. |
| `campaignId` | string | Same campaign id when the worker echoes it. |
| `subject` | string | Subject line. |
| `status` | string | `SCHEDULED` after accept. Immediate sends stay `SCHEDULED` until the worker starts. |
| `scheduledAt` | string | ISO 8601 scheduled time. |
| `timezone` | string | Schedule timezone when provided. |
| `recipientCount` | integer | Recipients at schedule time. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the `send` permission |
| 404 | `not_found` | Unknown campaign id |
| 400 | `validation_error` | Missing unsubscribe link, not a draft, or past `scheduledAt` |

## Related

- [Get Campaign](/docs/api-reference/v2/campaigns-get)
- [SDK: lumail.campaigns.send](/docs/sdk/v2/campaigns-send)


## API Reference
**Method:** POST
**Endpoint:** /api/v2/campaigns/:campaignId/send

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.campaigns.send("cmp_abc123", {
  scheduledAt: "2026-09-10T09:00:00.000Z",
  timezone: "Europe/Paris",
});
if (error) {
  throw error;
}
console.log(data.status, data.scheduledAt);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/campaigns/cmp_abc123/send \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"scheduledAt":"2026-09-10T09:00:00.000Z","timezone":"Europe/Paris"}'
```

### JavaScript
```javascript
const response = await fetch(
  "https://lumail.io/api/v2/campaigns/cmp_abc123/send",
  {
    method: "POST",
    headers: {
      Authorization: "Bearer YOUR_API_TOKEN",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      scheduledAt: "2026-09-10T09:00:00.000Z",
      timezone: "Europe/Paris",
    }),
  },
);
const data = await response.json();
```

### Success Response
```json
{
  "id": "cmp_abc123",
  "subject": "Hello",
  "status": "SCHEDULED",
  "scheduledAt": "2026-09-10T09:00:00.000Z",
  "recipientCount": 120
}
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "Campaign must have an unsubscribe link before scheduling. Add an unsubscribe link to the email content.",
  "statusCode": 400
}
```

---

# Tags
Source: https://lumail.io/docs/api-reference/v2/tags.md



---

# List Tags
Source: https://lumail.io/docs/api-reference/v2/tags-list.md

Lists audience tags. Permission: `audience`.

SDK `tags.list()` takes no args. HTTP accepts optional `?limit=` to slice the list.

## Query Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `limit` | integer | Optional slice. When the org has more tags than `limit`, `has_more` is `true`. |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `object` | `"list"` | Discriminator. Always `list`. |
| `has_more` | boolean | `true` when `limit` truncated the list. |
| `data` | array | Tag objects: `id`, `name`, `subscriberCount`. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the `audience` permission |

## Related

- [Create Tag](/docs/api-reference/v2/tags-create)
- [Get Tag](/docs/api-reference/v2/tags-get)
- [SDK: lumail.tags.list](/docs/sdk/v2/tags-list)


## API Reference
**Method:** GET
**Endpoint:** /api/v2/tags

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.tags.list();
if (error) {
  throw error;
}

for (const tag of data.data) {
  console.log(tag.id, tag.name);
}
```

### cURL
```bash
curl "https://lumail.io/api/v2/tags?limit=20" \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/tags?limit=20", {
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
  },
});
const page = await response.json();
```

### Success Response
```json
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "tag_abc123",
      "name": "customer",
      "subscriberCount": 12
    }
  ]
}
```

### Error Response
```json
{
  "name": "missing_permission",
  "message": "Missing permission: audience",
  "statusCode": 403
}
```

---

# Create Tag
Source: https://lumail.io/docs/api-reference/v2/tags-create.md

Creates an audience tag. If the name already exists (case-insensitive), returns the existing tag. Permission: `audience`.

## Body Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `name` | string | **Required.** Tag name. Min 1, max 100. |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | string | Tag id. |
| `name` | string | Tag name. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the `audience` permission |
| 422 | `validation_error` | Empty or missing `name` |

## Related

- [Get Tag](/docs/api-reference/v2/tags-get)
- [List Tags](/docs/api-reference/v2/tags-list)
- [SDK: lumail.tags.create](/docs/sdk/v2/tags-create)


## API Reference
**Method:** POST
**Endpoint:** /api/v2/tags

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.tags.create({ name: "customer" });
if (error) {
  throw error;
}
console.log(data.id, data.name);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/tags \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"customer"}'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/tags", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ name: "customer" }),
});
const data = await response.json();
```

### Success Response
```json
{
  "id": "tag_abc123",
  "name": "customer"
}
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "Name is required",
  "statusCode": 422
}
```

---

# Get Tag
Source: https://lumail.io/docs/api-reference/v2/tags-get.md

Returns one tag by id or name. Permission: `audience`.

## Path Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `tag` | string | **Required.** Tag id or name. |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | string | Tag id. |
| `name` | string | Tag name. |
| `subscribersCount` | integer | Contacts with this tag. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the `audience` permission |
| 404 | `not_found` | Unknown id or name |

## Related

- [Update Tag](/docs/api-reference/v2/tags-update)
- [List Tags](/docs/api-reference/v2/tags-list)
- [SDK: lumail.tags.get](/docs/sdk/v2/tags-get)


## API Reference
**Method:** GET
**Endpoint:** /api/v2/tags/:tag

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.tags.get("customer");
if (error) {
  throw error;
}
console.log(data.id, data.subscribersCount);
```

### cURL
```bash
curl https://lumail.io/api/v2/tags/customer \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/tags/customer", {
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
  },
});
const data = await response.json();
```

### Success Response
```json
{
  "id": "tag_abc123",
  "name": "customer",
  "subscribersCount": 12
}
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Tag not found",
  "statusCode": 404
}
```

---

# Update Tag
Source: https://lumail.io/docs/api-reference/v2/tags-update.md

Renames an audience tag. `:tag` is an id or current name. Permission: `audience`.

## Path Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `tag` | string | **Required.** Tag id or current name. |

## Body Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `name` | string | **Required.** New tag name. Min 1, max 100. |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | string | Tag id. |
| `name` | string | New name. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the `audience` permission |
| 404 | `not_found` | Unknown id or name |
| 422 | `validation_error` | Empty or missing `name` |

## Related

- [Get Tag](/docs/api-reference/v2/tags-get)
- [SDK: lumail.tags.update](/docs/sdk/v2/tags-update)


## API Reference
**Method:** PATCH
**Endpoint:** /api/v2/tags/:tag

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.tags.update("customer", {
  name: "vip",
});
if (error) {
  throw error;
}
console.log(data.name);
```

### cURL
```bash
curl -X PATCH https://lumail.io/api/v2/tags/customer \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"vip"}'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/tags/customer", {
  method: "PATCH",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ name: "vip" }),
});
const data = await response.json();
```

### Success Response
```json
{
  "id": "tag_abc123",
  "name": "vip"
}
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Tag not found",
  "statusCode": 404
}
```

---

# Events
Source: https://lumail.io/docs/api-reference/v2/events.md

Records a payment or refund on a subscriber. Permission: `subscribers`.

`eventType` is `SUBSCRIBER_PAYMENT` or `SUBSCRIBER_REFUND`. `subscriber` is an email or id. `data.amount` updates subscriber revenue.

## Body Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `eventType` | string | **Required.** `SUBSCRIBER_PAYMENT` or `SUBSCRIBER_REFUND`. |
| `subscriber` | string | **Required.** Subscriber email or id. |
| `data` | object | **Required.** Event payload. Use `amount` (integer cents) and optional `currency` / extra keys. |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | string | Created event id. |
| `eventType` | string | `SUBSCRIBER_PAYMENT` or `SUBSCRIBER_REFUND`. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the `subscribers` permission |
| 404 | `not_found` | Unknown subscriber |
| 400 | `validation_error` | Unsupported `eventType` or invalid payload |

## Related

- [List Subscriber Events](/docs/api-reference/v2/subscribers-events)
- [SDK: lumail.events.create](/docs/sdk/v2/events-create)


## API Reference
**Method:** POST
**Endpoint:** /api/v2/events

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.events.create({
  eventType: "SUBSCRIBER_PAYMENT",
  subscriber: "user@example.com",
  data: { amount: 2000, currency: "usd" },
});
if (error) {
  throw error;
}
console.log(data.id, data.eventType);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/events \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"eventType":"SUBSCRIBER_PAYMENT","subscriber":"user@example.com","data":{"amount":2000,"currency":"usd"}}'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/events", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    eventType: "SUBSCRIBER_PAYMENT",
    subscriber: "user@example.com",
    data: { amount: 2000, currency: "usd" },
  }),
});
const data = await response.json();
```

### Success Response
```json
{
  "id": "evt_abc123",
  "eventType": "SUBSCRIBER_PAYMENT"
}
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Subscriber not found",
  "statusCode": 404
}
```

---

# Tokens
Source: https://lumail.io/docs/api-reference/v2/tokens.md

Permission: `tokens`. The secret is returned only on create.

---

# List Tokens
Source: https://lumail.io/docs/api-reference/v2/tokens-list.md

Lists organization API tokens. Secrets are last-4 only. Permission: `tokens`.

SDK `tokens.list()` takes no args. HTTP accepts optional `?limit=` to slice the list.

## Query Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `limit` | integer | Optional slice. When the org has more tokens than `limit`, `has_more` is `true`. |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `object` | `"list"` | Discriminator. Always `list`. |
| `has_more` | boolean | `true` when `limit` truncated the list. |
| `data` | array | Token summaries: `id`, `name`, `last4`, `permissions`, `createdAt`. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the `tokens` permission |

## Related

- [Create Token](/docs/api-reference/v2/tokens-create)
- [SDK: lumail.tokens.list](/docs/sdk/v2/tokens-list)


## API Reference
**Method:** GET
**Endpoint:** /api/v2/tokens

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.tokens.list();
if (error) {
  throw error;
}

for (const token of data.data) {
  console.log(token.id, token.last4);
}
```

### cURL
```bash
curl "https://lumail.io/api/v2/tokens?limit=20" \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/tokens?limit=20", {
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
  },
});
const page = await response.json();
```

### Success Response
```json
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "tok_abc123",
      "name": "CI",
      "last4": "a1b2",
      "permissions": ["subscribers", "emails", "audience"],
      "createdAt": "2026-09-05T10:30:00.000Z"
    }
  ]
}
```

### Error Response
```json
{
  "name": "missing_permission",
  "message": "Missing permission: tokens",
  "statusCode": 403
}
```

---

# Create Token
Source: https://lumail.io/docs/api-reference/v2/tokens-create.md

Mints a `lum_` token. The secret is returned once. Permission: `tokens`.

Use either `preset` or `permissions`, not both. `reuseIfExists` defaults to `true`.

## Body Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `name` | string | **Required.** Token label. Min 1, max 100. |
| `reuseIfExists` | boolean | Return an existing token with the same name instead of minting. Default `true`. |
| `permissions` | string[] | Explicit scopes: `subscribers`, `audience`, `campaigns`, `send`, `workflows`, `emails`, `analytics`, `settings`, `domains`, `tokens`, or `*`. |
| `preset` | `"app"` \| `"marketing"` \| `"sender"` \| `"full"` | Permission preset. Do not combine with `permissions`. |

Presets: `app` = subscribers + emails + audience. `marketing` adds campaigns, analytics, workflows. `sender` adds `send`. `full` is `*`.

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | string | Token id. |
| `name` | string | Token label. |
| `token` | string | Full `lum_...` secret. Returned once. |
| `permissions` | string[] | Granted scopes. |
| `createdAt` | string | ISO 8601 created timestamp. |
| `reused` | boolean | `true` when an existing token was returned. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the `tokens` permission, or cannot mint the requested scopes |
| 422 | `validation_error` | Invalid name, or both `preset` and `permissions` |

## Related

- [List Tokens](/docs/api-reference/v2/tokens-list)
- [SDK: lumail.tokens.create](/docs/sdk/v2/tokens-create)


## API Reference
**Method:** POST
**Endpoint:** /api/v2/tokens

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.tokens.create({
  name: "CI",
  preset: "app",
});
if (error) {
  throw error;
}
console.log(data.token);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/tokens \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"CI","preset":"app"}'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/tokens", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ name: "CI", preset: "app" }),
});
const data = await response.json();
```

### Success Response
```json
{
  "id": "tok_abc123",
  "name": "CI",
  "token": "lum_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "permissions": ["subscribers", "emails", "audience"],
  "createdAt": "2026-09-05T10:30:00.000Z",
  "reused": false
}
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "Use either preset or permissions, not both",
  "statusCode": 422
}
```

---

# Tools
Source: https://lumail.io/docs/api-reference/v2/tools.md

Same operations as the MCP tools, over HTTP.

---

# List Tools
Source: https://lumail.io/docs/api-reference/v2/tools-list.md

Lists tools the token can execute. Permission: any valid token; the catalog is filtered to the token's scopes.

SDK `tools.list()` takes no args. HTTP accepts optional `?limit=` to slice the list.

## Query Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `limit` | integer | Optional slice. When more tools exist than `limit`, `has_more` is `true`. |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `object` | `"list"` | Discriminator. Always `list`. |
| `has_more` | boolean | `true` when `limit` truncated the list. |
| `data` | array | Tool schemas: `name`, `description`, `inputSchema`, `endpoint`. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |

## Related

- [Get Tool](/docs/api-reference/v2/tools-get)
- [Execute Tool](/docs/api-reference/v2/tools-execute)
- [SDK: lumail.tools.list](/docs/sdk/v2/tools-list)


## API Reference
**Method:** GET
**Endpoint:** /api/v2/tools

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.tools.list();
if (error) {
  throw error;
}

for (const tool of data.data) {
  console.log(tool.name, tool.endpoint);
}
```

### cURL
```bash
curl "https://lumail.io/api/v2/tools?limit=20" \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/tools?limit=20", {
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
  },
});
const page = await response.json();
```

### Success Response
```json
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "name": "verify_email",
      "description": "Check syntax and deliverability signals for one address.",
      "inputSchema": { "type": "object" },
      "endpoint": "/api/v2/tools/verify_email"
    }
  ]
}
```

### Error Response
```json
{
  "name": "missing_api_key",
  "message": "Missing API key",
  "statusCode": 401
}
```

---

# Get Tool
Source: https://lumail.io/docs/api-reference/v2/tools-get.md

Returns one tool schema. Permission must include the tool's required scope.

## Path Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `toolName` | string | **Required.** Tool name, e.g. `list_subscribers`. |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `name` | string | Tool name. |
| `description` | string | What the tool does. |
| `category` | string | Tool group. |
| `inputSchema` | object | JSON Schema for the POST body. |
| `outputSchema` | object | JSON Schema for the tool result. |
| `endpoint` | string | Execute path: `/api/v2/tools/{name}`. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the tool's permission |
| 404 | `not_found` | Unknown tool name |

## Related

- [Execute Tool](/docs/api-reference/v2/tools-execute)
- [List Tools](/docs/api-reference/v2/tools-list)
- [SDK: lumail.tools.get](/docs/sdk/v2/tools-get)


## API Reference
**Method:** GET
**Endpoint:** /api/v2/tools/:toolName

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.tools.get("list_subscribers");
if (error) {
  throw error;
}
console.log(data.name, data.inputSchema);
```

### cURL
```bash
curl https://lumail.io/api/v2/tools/list_subscribers \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch(
  "https://lumail.io/api/v2/tools/list_subscribers",
  {
    headers: {
      Authorization: "Bearer YOUR_API_TOKEN",
    },
  },
);
const data = await response.json();
```

### Success Response
```json
{
  "name": "list_subscribers",
  "description": "List subscribers in your organization.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "limit": { "type": "integer" }
    }
  },
  "endpoint": "/api/v2/tools/list_subscribers"
}
```

### Error Response
```json
{
  "name": "not_found",
  "message": "Tool \"not_a_real_tool\" not found",
  "statusCode": 404
}
```

---

# Execute Tool
Source: https://lumail.io/docs/api-reference/v2/tools-execute.md

Runs a tool by name. Body is the tool input from [Get Tool](/docs/api-reference/v2/tools-get). Example: `POST /api/v2/tools/list_subscribers`.

Dangerous tools return `confirmation_required` with `details.confirmationCode`. Retry the same body plus `confirmationCode`. SDK: `lumail.tools.run(name, params, { autoConfirm: true })` retries once.

## Path Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `toolName` | string | **Required.** Tool name. |

## Body Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| *(tool fields)* | varies | Fields from the tool `inputSchema`. Example `list_subscribers`: `limit`, `status`, `tag`, `query`, `cursor`. |
| `confirmationCode` | integer | Required on the retry after `confirmation_required`. |

## Options

| Name | Type | Description |
| ---- | ---- | ----------- |
| `autoConfirm` | boolean | SDK-only. When `true`, `tools.run` retries once with `confirmationCode`. HTTP clients send `confirmationCode` themselves. Default `false`. |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| *(tool output)* | object | Tool-specific JSON. Not wrapped in `{ success: true }`. |

`confirmation_required` error fields:

| Field | Type | Description |
| ----- | ---- | ----------- |
| `name` | `"confirmation_required"` | Error name. |
| `message` | string | Why confirmation is required. |
| `statusCode` | `403` | HTTP status. |
| `details.action` | string | Tool / action name. |
| `details.confirmationCode` | integer | Code to send back. |
| `details.instruction` | string | How to confirm. |

## Errors

| Status | `name` | When |
| ------ | ------ | ---- |
| 401 | `missing_api_key` | Missing or invalid bearer token |
| 403 | `missing_permission` | Token lacks the tool's permission |
| 403 | `confirmation_required` | Dangerous tool needs `confirmationCode` |
| 404 | `not_found` | Unknown tool name |
| 400 | `validation_error` | Invalid tool input |

## Related

- [Get Tool](/docs/api-reference/v2/tools-get)
- [List Tools](/docs/api-reference/v2/tools-list)
- [SDK: lumail.tools.run](/docs/sdk/v2/tools-run)


## API Reference
**Method:** POST
**Endpoint:** /api/v2/tools/:toolName

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.tools.run("list_subscribers", {
  limit: 10,
});
if (error) {
  throw error;
}
console.log(data);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/tools/list_subscribers \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"limit":10}'
```

### JavaScript
```javascript
const response = await fetch(
  "https://lumail.io/api/v2/tools/list_subscribers",
  {
    method: "POST",
    headers: {
      Authorization: "Bearer YOUR_API_TOKEN",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ limit: 10 }),
  },
);
const data = await response.json();
```

### Success Response
```json
{
  "total": 2,
  "count": 2,
  "subscribers": [
    {
      "id": "sub_abc123",
      "email": "user@example.com"
    }
  ],
  "nextCursor": null
}
```

### Error Response
```json
{
  "name": "confirmation_required",
  "message": "This will send up to 100 transactional emails immediately.",
  "statusCode": 403,
  "details": {
    "action": "send_email",
    "confirmationCode": 482193,
    "instruction": "Retry with confirmationCode to proceed."
  }
}
```

---

# Send Email in HTML
Source: https://lumail.io/docs/api-reference/api-emails-html.md

The `HTML` content type allows you to send raw HTML emails with full design control. Use this when you have existing HTML templates or need pixel-perfect layouts.

> **Test-mode recipients.** Fixture addresses such as `user@example.com` never send real email. See [Test-mode recipients](/docs/api-reference/test-mode).

Building templates as React components? See [Send React Email Templates with Lumail](/docs/tutorials/react-email).

## Quick Example

```bash
curl -X POST https://lumail.io/api/v1/emails \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "user@example.com",
    "subject": "Welcome {{name}}!",
    "content": "<html><body><h1>Hello {{name}}!</h1><p>Welcome to our platform.</p></body></html>",
    "contentType": "HTML",
    "from": "hello@yourdomain.com"
  }'
```

```typescript
import { Lumail } from "lumail";
const lumail = new Lumail({ apiKey: "YOUR_API_TOKEN" });

await lumail.emails.send({
  to: "user@example.com",
  subject: "Welcome {{name}}!",
  content:
    "<html><body><h1>Hello {{name}}!</h1><p>Welcome to our platform.</p></body></html>",
  contentType: "HTML",
  from: "hello@yourdomain.com",
});
```

## Variable Substitution

Use double curly braces in your HTML to insert subscriber data:

```html
<h1>Hello {{name | 'Friend'}}!</h1>
<p>Your email is {{email}}.</p>
```

### Available Variables

| Variable             | Description                                     |
| -------------------- | ----------------------------------------------- |
| `{{name}}`           | Subscriber's name                               |
| `{{email}}`          | Subscriber's email address                      |
| `{{phone}}`          | Subscriber's phone number                       |
| `{{unsubscribeUrl}}` | Unsubscribe link (empty on transactional sends) |
| `{{onlineUrl}}`      | View in browser link                            |
| `{{customField}}`    | Any custom field                                |

Placeholder names must be word characters. Hyphenated organization variables such as `{{org-name}}` are not substituted in `HTML` content and are delivered literally.

## Complete Example

```json
{
  "to": "customer@example.com",
  "subject": "Your order #{{orderNumber}} is confirmed!",
  "contentType": "HTML",
  "content": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"></head><body style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;\"><h1 style=\"color: #333;\">Thanks for your order, {{name}}!</h1><p>Your order <strong>#{{orderNumber}}</strong> has been confirmed.</p><table style=\"width: 100%; border-collapse: collapse; margin: 20px 0;\"><tr><td style=\"padding: 10px; border-bottom: 1px solid #eee;\">Total:</td><td style=\"padding: 10px; border-bottom: 1px solid #eee;\">{{orderTotal}}</td></tr><tr><td style=\"padding: 10px; border-bottom: 1px solid #eee;\">Shipping:</td><td style=\"padding: 10px; border-bottom: 1px solid #eee;\">{{shippingMethod}}</td></tr></table><p>Questions? <a href=\"mailto:support@yourstore.com\">Contact us</a></p></body></html>",
  "from": "orders@yourstore.com",
  "preview": "Your order has been confirmed"
}
```

## Email-Safe HTML Guidelines

### Use Inline Styles

Email clients have limited CSS support. Always use inline styles:

```html
<!-- Good -->
<p style="color: #333; font-size: 16px;">Hello!</p>

<!-- Avoid -->
<style>
  .text {
    color: #333;
  }
</style>
<p class="text">Hello!</p>
```

### Use Tables for Layout

For complex layouts, use tables instead of flexbox or grid:

```html
<table width="100%" cellpadding="0" cellspacing="0">
  <tr>
    <td width="50%" style="padding: 10px;">Left column</td>
    <td width="50%" style="padding: 10px;">Right column</td>
  </tr>
</table>
```

### Set Image Dimensions

Always specify width and height for images:

```html
<img
  src="https://example.com/logo.png"
  width="200"
  height="50"
  alt="Company Logo"
  style="display: block;"
/>
```

### Use Web-Safe Fonts

Stick to fonts available on all systems:

```html
<p style="font-family: Arial, Helvetica, sans-serif;">Safe fonts</p>
<p style="font-family: Georgia, Times, serif;">Safe serif</p>
```

## Automatic Features

When using HTML format, Lumail automatically:

- **Adds unsubscribe link** on non-transactional sends when `{{unsubscribeUrl}}` is absent (appended at the bottom)
- **Adds tracking pixel** for open tracking, inserted before `</body>`
- **Generates plain text** version by stripping HTML tags

Click tracking rewrites Markdown-style `[text](url)` links, so `<a href>` URLs in HTML content are delivered unchanged.

## Including Unsubscribe Link

If you want to control the placement of the unsubscribe link, include it in your HTML:

```html
<p style="text-align: center; font-size: 12px; color: #666;">
  <a href="{{unsubscribeUrl}}">Unsubscribe</a>
</p>
```

If you don't include `{{unsubscribeUrl}}`, Lumail will automatically append an unsubscribe link at the bottom of your email.

## Best Practices

1. **Test across email clients** - Use tools like Litmus or Email on Acid
2. **Keep width under 600px** - Ensures proper display on mobile
3. **Use alt text for images** - Many clients block images by default
4. **Avoid JavaScript** - Not supported in email clients
5. **Include plain text fallback** - Lumail generates this automatically
6. **Use absolute URLs** - Relative URLs won't work in emails


## API Reference
**Method:** POST
**Endpoint:** /api/v1/emails

---

# Send Email in Markdown
Source: https://lumail.io/docs/api-reference/api-emails-markdown.md

The `MARKDOWN` content type is the default format for sending transactional emails. It provides a simple way to write formatted emails using standard Markdown syntax.

> **Test-mode recipients.** Fixture addresses such as `user@example.com` never send real email. See [Test-mode recipients](/docs/api-reference/test-mode).

## Quick Example

```bash
curl -X POST https://lumail.io/api/v1/emails \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "user@example.com",
    "subject": "Welcome {{name}}!",
    "content": "# Hello {{name}}!\n\nWelcome to our platform.\n\n## Getting Started\n\n- Step one\n- Step two\n- Step three",
    "contentType": "MARKDOWN",
    "from": "hello@yourdomain.com"
  }'
```

```typescript
import { Lumail } from "lumail";
const lumail = new Lumail({ apiKey: "YOUR_API_TOKEN" });

await lumail.emails.send({
  to: "user@example.com",
  subject: "Welcome {{name}}!",
  content:
    "# Hello {{name}}!\n\nWelcome to our platform.\n\n## Getting Started\n\n- Step one\n- Step two\n- Step three",
  contentType: "MARKDOWN",
  from: "hello@yourdomain.com",
});
```

## Supported Markdown Syntax

### Text Formatting

```markdown
This is **bold text** and this is _italic text_.

You can also use ~~strikethrough~~ text.
```

### Headings

```markdown
# Heading 1

## Heading 2

### Heading 3
```

### Lists

```markdown
Unordered list:

- Item one
- Item two
- Item three

Ordered list:

1. First step
2. Second step
3. Third step
```

### Links

```markdown
Visit our [website](https://example.com) for more information.

Or use a raw URL: https://example.com
```

### Images

```markdown
![Alt text](https://example.com/image.png)
```

### Blockquotes

```markdown
> This is a blockquote.
> It can span multiple lines.
```

### Horizontal Rules

```markdown
Content above

---

Content below
```

## Variable Substitution

Use double curly braces to insert subscriber data:

```markdown
Hello {{name | 'there'}}!

Your email is {{email}}.
```

### Available Variables

| Variable             | Description                |
| -------------------- | -------------------------- |
| `{{name}}`           | Subscriber's name          |
| `{{email}}`          | Subscriber's email address |
| `{{phone}}`          | Subscriber's phone number  |
| `{{unsubscribeUrl}}` | Unsubscribe link           |
| `{{onlineUrl}}`      | View in browser link       |
| `{{org-name}}`       | Organization name          |
| `{{customField}}`    | Any custom field           |

### Default Values

Provide fallback values for empty fields:

```markdown
Hello {{name | 'Friend'}}!
```

If `name` is empty, "Friend" will be used instead.

## Complete Example

```json
{
  "to": "customer@example.com",
  "subject": "Your order #{{orderNumber}} is confirmed!",
  "contentType": "MARKDOWN",
  "content": "# Thanks for your order, {{name}}!\n\nYour order **#{{orderNumber}}** has been confirmed.\n\n## Order Summary\n\n- **Total:** {{orderTotal}}\n- **Shipping:** {{shippingMethod}}\n- **Estimated Delivery:** {{deliveryDate}}\n\n---\n\nQuestions? Reply to this email or visit our [Help Center](https://example.com/help).\n\nThanks for shopping with us!",
  "from": "orders@yourstore.com",
  "replyTo": "support@yourstore.com",
  "preview": "Your order has been confirmed"
}
```

## Automatic Features

When using Markdown format, Lumail automatically:

- **Converts to HTML** for rich email client rendering
- **Generates plain text** version for compatibility
- **Adds unsubscribe link** if not present in content
- **Tracks opens** via invisible pixel
- **Tracks clicks** by wrapping links

## Best Practices

1. **Use headings** to structure your content
2. **Keep paragraphs short** for better readability
3. **Use lists** for multi-step instructions
4. **Always provide fallbacks** for personalization variables
5. **Test with subscribers** who have missing data fields


## API Reference
**Method:** POST
**Endpoint:** /api/v1/emails

---

# Send Email in Tiptap
Source: https://lumail.io/docs/api-reference/api-emails-tiptap.md

The `TIPTAP` content type uses the same JSON format as the Lumail email editor. This is ideal for programmatically generating complex email layouts with rich components like buttons, images, columns, and more.

> **Test-mode recipients.** Fixture addresses such as `user@example.com` never send real email. See [Test-mode recipients](/docs/api-reference/test-mode).

## Quick Example

```bash
curl -X POST https://lumail.io/api/v1/emails \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "user@example.com",
    "subject": "Welcome!",
    "contentType": "TIPTAP",
    "content": "{\"type\":\"doc\",\"content\":[{\"type\":\"heading\",\"attrs\":{\"level\":1},\"content\":[{\"type\":\"text\",\"text\":\"Hello \"}]},{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"Welcome to our platform!\"}]}]}",
    "from": "hello@yourdomain.com"
  }'
```

```typescript
import { Lumail } from "lumail";
const lumail = new Lumail({ apiKey: "YOUR_API_TOKEN" });

await lumail.emails.send({
  to: "user@example.com",
  subject: "Welcome!",
  contentType: "TIPTAP",
  content: JSON.stringify({
    type: "doc",
    content: [
      {
        type: "heading",
        attrs: { level: 1 },
        content: [{ type: "text", text: "Hello " }],
      },
      {
        type: "paragraph",
        content: [{ type: "text", text: "Welcome to our platform!" }],
      },
    ],
  }),
  from: "hello@yourdomain.com",
});
```

## JSON Structure

Tiptap uses a tree-based JSON structure:

```json
{
  "type": "doc",
  "content": [
    {
      "type": "paragraph",
      "content": [
        {
          "type": "text",
          "text": "Hello world!"
        }
      ]
    }
  ]
}
```

## Supported Node Types

### Text Nodes

```json
{
  "type": "paragraph",
  "content": [
    {
      "type": "text",
      "text": "Regular text"
    }
  ]
}
```

### Headings

```json
{
  "type": "heading",
  "attrs": { "level": 1 },
  "content": [{ "type": "text", "text": "Heading 1" }]
}
```

### Variables

Insert subscriber data with the `variable` node:

```json
{
  "type": "variable",
  "attrs": {
    "id": "name",
    "fallback": "Friend"
  }
}
```

### Buttons

```json
{
  "type": "button",
  "attrs": {
    "text": "Click Here",
    "url": "https://example.com",
    "variant": "filled",
    "buttonColor": "#007bff",
    "textColor": "#ffffff"
  }
}
```

### Images

```json
{
  "type": "image",
  "attrs": {
    "src": "https://example.com/image.png",
    "alt": "Description",
    "width": 600,
    "alignment": "center"
  }
}
```

### Survey

One-question poll. Recipients click an answer in the inbox. Same node as the
campaign editor — see [Surveys](/docs/features/surveys) for settings, filters
and the workflow trigger.

```json
{
  "type": "survey",
  "attrs": {
    "surveyId": "svy_abcdefghij",
    "question": "What should we write about next?",
    "style": "buttons",
    "allowComment": false,
    "alignment": "left",
    "buttonColor": "#000000",
    "textColor": "#ffffff",
    "options": [
      { "id": "opt_a1b2c3d4e5", "label": "Talk about AI", "emoji": "🤖" },
      { "id": "opt_f6g7h8i9j0", "label": "No AI please", "emoji": "🙅" }
    ]
  }
}
```

| Attr | Required | Values |
| ---- | -------- | ------ |
| `surveyId` | yes | `svy_` + 10 alphanumeric characters, unique in the document |
| `question` | yes | 1–300 characters, no merge fields |
| `style` | no | `buttons` (default), `yes_no`, `nps`, `emoji` |
| `options` | yes* | 2–8 `{ id, label, emoji? }`. Ignored for `nps` (fixed `nps_0`…`nps_10`) |
| `allowComment` | no | `true` / `false` (default). Comment max 1000 characters |
| `npsLowLabel` / `npsHighLabel` | no | Author captions, max 60. `null` translates on the vote page |
| `alignment` | no | `left` (default), `center`, `right` |
| `buttonColor` / `textColor` | no | Hex. Defaults black / white |

`surveyId` is the join key for every recorded answer. Do not reuse one across
campaigns. Copying a campaign regenerates it. Surveys cannot live inside a
`repeat` block.

### Spacer

```json
{
  "type": "spacer",
  "attrs": {
    "height": 20
  }
}
```

### Horizontal Rule

```json
{
  "type": "horizontalRule"
}
```

### Columns (Two-Column Layout)

```json
{
  "type": "columns",
  "attrs": {
    "borderColor": "",
    "borderRadius": "0",
    "borderWidth": "0"
  },
  "content": [
    {
      "type": "column",
      "attrs": { "width": 50 },
      "content": [
        {
          "type": "paragraph",
          "content": [{ "type": "text", "text": "Left column" }]
        }
      ]
    },
    {
      "type": "column",
      "attrs": { "width": 50 },
      "content": [
        {
          "type": "paragraph",
          "content": [{ "type": "text", "text": "Right column" }]
        }
      ]
    }
  ]
}
```

## Text Formatting (Marks)

Apply formatting with marks:

```json
{
  "type": "text",
  "text": "Bold and italic",
  "marks": [{ "type": "bold" }, { "type": "italic" }]
}
```

### Available Marks

| Mark        | Description     |
| ----------- | --------------- |
| `bold`      | Bold text       |
| `italic`    | Italic text     |
| `underline` | Underlined text |
| `strike`    | Strikethrough   |
| `link`      | Hyperlink       |

### Link Mark

```json
{
  "type": "text",
  "text": "Visit our website",
  "marks": [
    {
      "type": "link",
      "attrs": {
        "href": "https://example.com",
        "target": "_blank"
      }
    }
  ]
}
```

## Complete Example

```json
{
  "to": "customer@example.com",
  "subject": "Welcome to our platform!",
  "contentType": "TIPTAP",
  "content": {
    "type": "doc",
    "content": [
      {
        "type": "heading",
        "attrs": { "level": 1, "textAlign": "center" },
        "content": [
          { "type": "text", "text": "Welcome, " },
          {
            "type": "variable",
            "attrs": { "id": "name", "fallback": "Friend" }
          },
          { "type": "text", "text": "!" }
        ]
      },
      {
        "type": "paragraph",
        "content": [
          {
            "type": "text",
            "text": "Thanks for signing up. We're excited to have you on board."
          }
        ]
      },
      {
        "type": "spacer",
        "attrs": { "height": 20 }
      },
      {
        "type": "button",
        "attrs": {
          "text": "Get Started",
          "url": "https://example.com/dashboard",
          "variant": "filled",
          "buttonColor": "#007bff",
          "textColor": "#ffffff",
          "alignment": "center"
        }
      },
      {
        "type": "spacer",
        "attrs": { "height": 30 }
      },
      {
        "type": "horizontalRule"
      },
      {
        "type": "footer",
        "attrs": { "textAlign": "center" },
        "content": [
          {
            "type": "text",
            "text": "You received this email because you signed up on our website."
          }
        ]
      }
    ]
  },
  "from": "welcome@yourcompany.com",
  "preview": "Welcome to our platform"
}
```

## Exporting from Lumail Editor

You can get the Tiptap JSON from the Lumail email editor:

1. Create your email in the Lumail editor
2. Open browser developer tools (F12)
3. In the console, access the editor's `getJSON()` method
4. Copy the JSON structure for API use

## Automatic Features

When using Tiptap format, Lumail automatically:

- **Adds unsubscribe footer** if no `{{unsubscribeUrl}}` is present
- **Replaces variables** with subscriber data
- **Tracks opens** via invisible pixel
- **Tracks clicks** by wrapping links
- **Generates plain text** version

## Best Practices

1. **Use the Lumail editor** to prototype your email structure
2. **Test with variables** to ensure fallbacks work correctly
3. **Keep layouts simple** for better email client compatibility
4. **Use buttons** for important calls-to-action
5. **Include alt text** for all images


## API Reference
**Method:** POST
**Endpoint:** /api/v1/emails

---

# List Subscribers
Source: https://lumail.io/docs/api-reference/api-subscribers-list.md

`GET https://lumail.io/api/v1/subscribers`

List subscribers in your organization with optional filtering by tag, status, or search query. Results are sorted by creation date (most recent first).

## Query Parameters

| Parameter | Type   | Default        | Description                                                                                                                   |
| --------- | ------ | -------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `tag`     | string | —              | Filter by tag name (case-insensitive). Only returns subscribers who have this tag.                                            |
| `status`  | string | `"SUBSCRIBED"` | Filter by subscriber status. One of: `SUBSCRIBED`, `UNSUBSCRIBED`, `PENDING_CONFIRMATION`, `BOUNCED`, `BANNED`, `COMPLAINED`, `TRANSACTIONAL`. |
| `limit`   | number | `50`           | Maximum number of subscribers to return (1–100).                                                                              |
| `query`   | string | —              | Search by name or email (case-insensitive partial match).                                                                     |

## Response

- **Success (200 OK)** — Returns a list of subscribers matching the filters.
- **Error (401 Unauthorized)** — Invalid or missing API token.
- **Error (429 Too Many Requests)** — Rate limit exceeded.

## Response Fields

| Field                       | Type           | Description                                         |
| --------------------------- | -------------- | --------------------------------------------------- |
| `success`                   | boolean        | Indicates if the operation was successful           |
| `subscribers`               | array          | Array of subscriber objects                         |
| `subscribers[].id`          | string         | Unique identifier for the subscriber                |
| `subscribers[].email`       | string         | Email address of the subscriber                     |
| `subscribers[].name`        | string or null | Name of the subscriber (if provided)                |
| `subscribers[].status`      | string         | Current status of the subscriber                    |
| `subscribers[].createdAt`   | string         | ISO 8601 timestamp of when the subscriber was added |
| `subscribers[].tags`        | array          | Array of tag objects associated with the subscriber |
| `subscribers[].tags[].id`   | string         | Unique identifier for the tag                       |
| `subscribers[].tags[].name` | string         | Name of the tag                                     |
| `total`                     | number         | Total number of subscribers matching the filters    |
| `limit`                     | number         | The limit used in the query                         |

## Usage Examples

Use this endpoint to:

- Fetch subscribers by tag for integrations (e.g., display recent signups from a specific campaign)
- Search for subscribers by name or email
- List subscribers with a specific status for reporting
- Power dashboards or external tools that need subscriber data


## API Reference
**Method:** GET
**Endpoint:** /api/v1/subscribers

### SDK
```ts
import { Lumail } from "lumail";
const lumail = new Lumail({ apiKey: "YOUR_API_TOKEN" });

// List all subscribers
const { subscribers, total } = await lumail.subscribers.list();

// Filter by tag, status, and search
const result = await lumail.subscribers.list({
  tag: "newsletter",
  status: "SUBSCRIBED",
  limit: 10,
  query: "john",
});
console.log(result.subscribers);
```

### cURL
```bash
# List all subscribed subscribers
curl -X GET "https://lumail.io/api/v1/subscribers" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

# Filter by tag
curl -X GET "https://lumail.io/api/v1/subscribers?tag=aiblueprint-dev&limit=10" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

# Filter by status and search
curl -X GET "https://lumail.io/api/v1/subscribers?status=SUBSCRIBED&query=john&limit=20" \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

### JavaScript
```javascript
const response = await fetch(
  'https://lumail.io/api/v1/subscribers?tag=newsletter&limit=10&status=SUBSCRIBED',
  {
    method: 'GET',
    headers: {
      'Authorization': 'Bearer YOUR_API_TOKEN',
    },
  }
);

const data = await response.json();
console.log(data.subscribers);
```

### Python
```python
import requests

url = "https://lumail.io/api/v1/subscribers"
headers = {
    "Authorization": "Bearer YOUR_API_TOKEN"
}
params = {
    "tag": "newsletter",
    "limit": 10,
    "status": "SUBSCRIBED"
}

response = requests.get(url, headers=headers, params=params)
data = response.json()
print(data)
```

### Success Response
```json
{
  "success": true,
  "subscribers": [
    {
      "id": "sub_TTYPR5tWDh",
      "email": "john@example.com",
      "name": "John Doe",
      "status": "SUBSCRIBED",
      "createdAt": "2026-04-02T10:30:00.000Z",
      "tags": [
        { "id": "tag_JqaddtEx7z", "name": "newsletter" }
      ]
    },
    {
      "id": "sub_Abc123xyz",
      "email": "jane@example.com",
      "name": null,
      "status": "SUBSCRIBED",
      "createdAt": "2026-04-01T08:15:00.000Z",
      "tags": [
        { "id": "tag_JqaddtEx7z", "name": "newsletter" },
        { "id": "tag_rdEPhyjnfA", "name": "vip" }
      ]
    }
  ],
  "total": 142,
  "limit": 10
}
```

### Error Response
```json
{
  "message": "Missing or invalid Authorization header"
}
```

---

# Get Tag by ID or Name
Source: https://lumail.io/docs/api-reference/api-tag-get.md

`https://lumail.io/api/v1/tags/{tag}`

Retrieves a specific tag by its ID or name, including the subscriber count. This endpoint is perfect for displaying waiting list counters or tag-based statistics on your landing pages.

## Path Parameters

| Parameter | Type   | Description                                    |
| --------- | ------ | ---------------------------------------------- |
| `tag`     | string | **Required.** The tag ID or tag name to lookup |

## Response

- **Success (200 OK)** - Returns the tag object with subscriber count.
- **Error (404 Not Found)** - Tag not found

## Response Fields

| Field                  | Type   | Description                                                          |
| ---------------------- | ------ | -------------------------------------------------------------------- |
| `tag`                  | object | The tag object                                                       |
| `tag.id`               | string | Unique identifier for the tag                                        |
| `tag.name`             | string | Name of the tag                                                      |
| `tag.subscribersCount` | number | Number of subscribers with this tag (excluding unsubscribed/bounced) |

## Usage Example

Use this API endpoint to:

- Display waiting list counters on landing pages
- Show tag-based subscriber statistics in your dashboard
- Get subscriber counts for specific audience segments
- Build dynamic counters for marketing campaigns

### Landing Page Counter Example

```javascript
// Fetch subscriber count for waiting list
const response = await fetch("https://lumail.io/api/v1/tags/waiting-list", {
  method: "GET",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
});

const data = await response.json();
const count = data.tag.subscribersCount;

// Update your landing page counter
document.getElementById("waiting-list-counter").textContent =
  `${count} people on the waiting list`;
```

### SDK Example

```typescript
import { Lumail } from "lumail";
const lumail = new Lumail({ apiKey: "YOUR_API_TOKEN" });

const { tag } = await lumail.tags.get("waiting-list");
console.log(`${tag.subscribersCount} people on the waiting list`);
```


## API Reference
**Method:** GET
**Endpoint:** /api/v1/tags/{tag}

### SDK
```ts
import { Lumail } from "lumail";
const lumail = new Lumail({ apiKey: "YOUR_API_TOKEN" });

const { tag } = await lumail.tags.get("waiting-list");
console.log(tag.subscribersCount);
```

### cURL
```bash
curl -X GET https://lumail.io/api/v1/tags/waiting-list \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json"
```

### JavaScript
```javascript
const response = await fetch('https://lumail.io/api/v1/tags/waiting-list', {
  method: 'GET',
  headers: {
    'Authorization': 'Bearer YOUR_API_TOKEN',
    'Content-Type': 'application/json',
  },
});

const data = await response.json();
console.log(data);
```

### Python
```python
import requests

url = "https://lumail.io/api/v1/tags/waiting-list"
headers = {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json"
}

response = requests.get(url, headers=headers)
data = response.json()
print(data)
```

### Success Response
```json
{
  "tag": {
    "id": "tag_JqaddtEx7z",
    "name": "waiting-list",
    "subscribersCount": 42
  }
}
```

### Error Response
```json
{
  "error": "Tag not found"
}
```

---

# API Reference
Source: https://lumail.io/docs/api-reference.md

Welcome to the Lumail.io API documentation. Our REST API allows you to integrate email marketing capabilities into your applications.

v1 below is frozen. New integrations use [API v2](/docs/api-reference/v2). Agents rewriting existing code should start at [Migration to V2](/docs/api-reference/v2/migration). See the [versioning and deprecation policy](/docs/api-reference/versioning) for how changes and sunsets are announced.

## Machine-readable discovery

- [OpenAPI 3.1 specification](https://lumail.io/openapi.json) - Typed operations, parameters, request bodies, responses, and error schemas for code generators and function calling
- [MCP server manifest](https://lumail.io/.well-known/mcp.json) - Canonical Streamable HTTP endpoint, OAuth discovery, and MCP documentation
- [MCP server](/docs/api-reference/mcp) - OAuth-protected tools and readable developer resources at `https://lumail.io/mcp`
- [llms.txt](https://lumail.io/llms.txt) - Markdown index for agents and retrieval systems
- [Authentication](/docs/api-reference/api-tokens) - Organization API token permissions and Bearer usage
- [Webhooks and integrations](/docs/integrations) - WordPress, ClickFunnels, Systeme.io, and automation setup
- [SDK](/docs/sdk/introduction) and [CLI](/docs/api-reference/cli) - Typed application and terminal access

## Getting Started

Before using the API, you'll need to:

1. [Generate an API token](/docs/api-reference/api-tokens)
2. Include the token in your requests using the `Authorization` header

## Available Endpoints

### Transactional Emails

- [Send Emails](/docs/api-reference/api-emails-send) - Send transactional emails to subscribers
- [Verify](/docs/api-reference/v2/verify) - Verify email addresses before sending

#### Email Content Formats

- [Send Email in Markdown](/docs/api-reference/api-emails-markdown) - Plain text with Markdown formatting
- [Send Email in HTML](/docs/api-reference/api-emails-html) - Raw HTML emails
- [Send Email in Tiptap](/docs/api-reference/api-emails-tiptap) - Tiptap JSON format

### Subscriber Management

- [Create Subscriber](/docs/api-reference/api-subscribers-post) - Add new subscribers
- [Get Subscribers](/docs/api-reference/api-subscribers-get) - Retrieve subscriber list
- [Update Subscriber](/docs/api-reference/api-subscribers-patch) - Update subscriber data
- [Unsubscribe Subscriber](/docs/api-reference/api-subscribers-unsubscribe) - Stop mail without deleting history

#### Subscriber Tags

- [Add Tags to Subscriber](/docs/api-reference/api-subscriber-tags-post) - Apply tags to subscribers
- [Remove Tags from Subscriber](/docs/api-reference/api-subscriber-tags-delete) - Remove tags from subscribers

### Events

Track and retrieve subscriber activity. Learn more about [all event types](/docs/features/subscriber-events).

- [Track Events](/docs/api-reference/api-events-post) - Record new subscriber events
- [Get Subscriber Events](/docs/api-reference/api-subscriber-events-get) - Retrieve event history for a subscriber

### Tags

- [Get Tags](/docs/api-reference/api-tags-get) - Retrieve tags
- [Create Tags](/docs/api-reference/api-tags-post) - Create new tags

## Authentication

All API requests require authentication using a Bearer token:

```bash
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
  https://api.lumail.io/v1/endpoint
```

## Rate Limits

API requests are rate-limited per organization. See [Rate Limits](/docs/api-reference/api-limits) for full details.

| Plan       | Requests per Minute |
| ---------- | ------------------- |
| Free       | 100                 |
| Premium    | 700                 |
| Enterprise | 2,000               |

## Tutorials

Step-by-step guides for common API integrations:

- [Create API Token](/docs/tutorials/create-api-token) - Generate tokens for API access
- [Dynamic Promo Codes](/docs/tutorials/dynamic-promo-codes) - Personalized coupons via webhooks

## Related Documentation

- [Features](/docs/features) - Platform features and capabilities
- [Workflows](/docs/workflows) - Automate email sequences
- [Integrations](/docs/integrations) - WordPress, ClickFunnels, SystemIO, and more
- [Email Domains](/docs/domains/email-domains) - Configure verified sending domains

---

# Verify Email
Source: https://lumail.io/docs/api-reference/v2/emails-verify.md

Prefer [`POST /api/v2/verify`](/docs/api-reference/v2/verify) or `lumail.verify()`. This path stays for existing `lumail.emails.verify` clients and still uses the same verification engine.

`valid: false` is still HTTP 200 with `{ error, code }`. Transport / auth failures use `{ name, message, statusCode }`.

## Body Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| `email` | string | **Required.** Address to check. |

## Response Fields

| Field | Type | Description |
| ----- | ---- | ----------- |
| `valid` | boolean | Policy decision. |
| `error` | string | Present when `valid` is `false`. |
| `code` | string | Present when `valid` is `false`. |
| `suggestion` | string | Corrected address when the domain looks like a typo. |
| `warnings` | string[] | Soft warnings when present. |

## Related

- [Verify](/docs/api-reference/v2/verify)
- [SDK: lumail.verify](/docs/sdk/v2/verify)


## API Reference
**Method:** POST
**Endpoint:** /api/v2/emails/verify

### SDK
```ts
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY });
const { data, error } = await lumail.emails.verify({
  email: "user@example.com",
});
if (error) {
  throw error;
}
console.log(data.valid);
```

### cURL
```bash
curl -X POST https://lumail.io/api/v2/emails/verify \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com"}'
```

### JavaScript
```javascript
const response = await fetch("https://lumail.io/api/v2/emails/verify", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ email: "user@example.com" }),
});
const data = await response.json();
```

### Success Response
```json
{
  "valid": true,
  "warnings": ["Role-based email address detected"]
}
```

### Error Response
```json
{
  "name": "validation_error",
  "message": "Email is required",
  "statusCode": 422
}
```

---

# WordPress Plugin
Source: https://lumail.io/docs/integrations/wordpress.md

The [Lumail WordPress plugin](https://github.com/Melvynx/lumail-opensource/tree/main/wordpress) renders `[lumail_form]` and upserts contacts with `POST /api/v2/subscribers`. The browser only talks to `admin-ajax.php`. The `lum_` token never leaves the WordPress server.

Requires WordPress 6.2+ and PHP 8.0+. Source: [github.com/Melvynx/lumail-opensource](https://github.com/Melvynx/lumail-opensource) (`wordpress/`).

## How it works

1. A visitor submits the form.
2. JavaScript `POST`s to `admin-ajax.php` (`lumail_subscribe`) with a nonce.
3. WordPress sanitizes the email, applies a honeypot and a one-minute rate limit, then calls Lumail:

```
POST https://lumail.io/api/v2/subscribers
Authorization: Bearer lum_…
```

| Field | Value |
| --- | --- |
| `email` | Required |
| `name` | Optional, if the name field is shown |
| `tags` | Default tags from Settings, merged with shortcode `tags` |
| `skipDoubleOptIn` | `false` — org double opt-in still applies |
| `resubscribe` | `false` — `UNSUBSCRIBED` contacts stay unsubscribed |
| `triggerWorkflows` | `true` |
| `ipAddress` | Visitor IP (Cloudflare `CF-Connecting-IP` when present) |

There is no Gutenberg block and no WooCommerce sync. For checkout or other trusted backends, use the [Create Subscriber API](/docs/api-reference/v2/subscribers-create) with `skipDoubleOptIn: true`.

## Install

```bash
git clone https://github.com/Melvynx/lumail-opensource.git
cp -R lumail-opensource/wordpress /path/to/wp-content/plugins/lumail
```

The plugin folder **must** be named `lumail` (it loads `lumail.php`). Then:

1. wp-admin → **Plugins** → activate **Lumail**
2. **Settings → Lumail**

## API token

Create a token with the **`subscribers`** permission:

1. Lumail → **Settings → API Tokens** (or follow [Create an API Token](/docs/tutorials/create-api-token))
2. Name it `WordPress` and pick the **App** preset, or a custom set that includes `subscribers`
3. Copy the `lum_…` secret once
4. Paste it in WordPress **Settings → Lumail**. Leave the field blank on later saves to keep the current token

The settings screen only shows `lum_…` plus the last four characters. **Test connection** is `GET /api/v2/subscribers?limit=1` — a 200 means the token can read subscribers.

Optional: set **Default tags** (`wordpress, newsletter`). They merge with tags on the shortcode.

## Shortcode

Put this on a page or post:

```
[lumail_form]
```

| Attribute | Default | Meaning |
| --- | --- | --- |
| `title` | empty | Heading above the fields |
| `button` | Settings button label (`Subscribe`) | Submit label |
| `show_name` | Settings checkbox | `true` / `false` |
| `tags` | empty | Extra tags, comma-separated, merged with defaults |

```
[lumail_form title="Get the newsletter" tags="blog" button="Join" show_name="true"]
```

Success copy comes from **Settings → Lumail → Success message**. With double opt-in on, keep it something like “Check your inbox to confirm.”

## Double opt-in and unsubscribes

The plugin is a **public form**. It never sends `skipDoubleOptIn: true` and never resubscribes.

- Org double opt-in on → new contacts are `PENDING_CONFIRMATION` and get the confirmation email. See [Enable Double Opt-In](/docs/tutorials/enable-double-opt-in).
- Someone already `UNSUBSCRIBED` stays unsubscribed. They do not re-enter the list from this form.
- Tag-triggered workflows still fire for new confirmed (or immediately subscribed) contacts.

## Troubleshooting

| Symptom | Check |
| --- | --- |
| “Lumail is not configured correctly.” | Token missing, not `lum_…`, or lacks `subscribers`. Save, then Test connection. |
| “Enter a valid email.” | Client-side or `sanitize_email` rejected the address. |
| “Wait a minute before trying again.” | One submit per email+IP per minute. |
| Form submits but nobody appears | Double opt-in: look under **Subscribers → Pending Confirmation**. |
| Unsubscribed contact does not come back | Expected. `resubscribe` is off. Use Lumail (or the API with `resubscribe: true`) if you mean to bring them back. |

A filled honeypot field (`website`) returns the same success message and does not call Lumail.

## Related

- [Create Subscriber](/docs/api-reference/v2/subscribers-create)
- [Create an API Token](/docs/tutorials/create-api-token)
- [Enable Double Opt-In](/docs/tutorials/enable-double-opt-in)
- Plugin source and how it is built: [wordpress/](https://github.com/Melvynx/lumail-opensource/tree/main/wordpress)

---

# ClickFunnels Integration
Source: https://lumail.io/docs/integrations/clickfunnel-integration.md

Automatically sync contacts, tags, and order events from [ClickFunnels](https://www.clickfunnels.com) to Lumail using webhooks. This integration allows you to centralize your email marketing data and leverage Lumail's advanced segmentation and automation features with your ClickFunnels audience.

## What is ClickFunnels?

ClickFunnels is a comprehensive sales funnel builder that helps businesses create marketing funnels, landing pages, and manage online sales. With this integration, you can sync your ClickFunnels contacts, tags, and order activity to Lumail in real-time.

## How It Works

When events occur in ClickFunnels (new contacts, tag changes, orders), ClickFunnels sends webhook notifications to Lumail. The integration:

1. **Receives** webhook events from ClickFunnels
2. **Processes** the event (creates/updates subscribers, syncs tags, tracks orders)
3. **Updates** your Lumail database in real-time
4. **Records** all events in the subscriber timeline

## Guided setup (recommended)

Lumail has a step-by-step wizard that generates the token and the webhook URL for you:

**Settings -> Integrations -> ClickFunnels**, at `https://lumail.io/orgs/<your-org-slug>/settings/integrations/clickfunnels`.

It gives you the exact values to paste into ClickFunnels. The manual steps below do the same thing by hand.

## Setup Instructions

Follow these steps to connect your ClickFunnels account to Lumail.

> These steps are for **ClickFunnels 2.0** (workspace webhooks). On **ClickFunnels Classic (1.0)**, webhooks are configured per funnel and use a different URL - see [ClickFunnels Classic (1.0)](#clickfunnels-classic-10).

### Step 1: Generate Your API Token

First, you need to generate an API token for the integration.

1. Visit your [organization settings page](https://lumail.io/orgs/default/settings/tokens)
2. Click **"Generate Token"** and give it a name like "ClickFunnels"
3. Copy the generated token - you'll need it for the webhook URL

### Step 2: Configure the Webhook in ClickFunnels

Now you'll add the webhook endpoint in your ClickFunnels workspace.

1. On the bottom left corner, click on **workspace settings**:

   ![ClickFunnels workspace settings](/docs/clickfunnel-1.png)

2. Then navigate to the **"Webhooks"** settings page:

   ![ClickFunnels webhooks page](/docs/clickfunnel-2.png)

3. You will be redirected to the webhooks endpoint page. Click on **"Add new webhook"** button:

   ![ClickFunnels add webhook](/docs/clickfunnel-3.png)

4. In the webhook form, fill in the following details:
   - **Name**: `Lumail.io`
   - **URL**: `https://lumail.io/api/v1/integrations/clickfunnel/{your-token}`
     - Replace `{your-token}` with the API token you generated in Step 1, including its `lum_` prefix
     - Example: `https://lumail.io/api/v1/integrations/clickfunnel/lum_1a2b3c...`
   - **Event types**: Select the events you want to sync:
     - ✅ **contact.created** - Syncs new contacts
     - ✅ **contact.updated** - Upserts existing contacts and tags
     - ✅ **contact.identified** - Syncs contacts identified from a form
     - ✅ **contact.deleted** - Unsubscribes contacts without deleting history
     - ✅ **contact.unsubscribed** - Marks contacts as unsubscribed
     - ✅ **orders/invoice.fulfilled** - Tracks successful orders
     - ✅ **orders/invoice.refunded** - Tracks refunded orders
   - **Endpoint scope**: Leave empty

5. Click **"Create endpoint"** and your integration will be ready

### Step 3: Test the Integration

To verify the integration is working:

1. Add a test contact in ClickFunnels with some tags
2. Check your Lumail organization's subscribers list
3. The contact should appear with all synced tags
4. Check the subscriber's timeline to see the webhook events

## ClickFunnels Classic (1.0)

ClickFunnels Classic has no workspace webhook settings, and its webhooks send a different payload than ClickFunnels 2.0. Use the dedicated Classic URL instead of the 2.0 one: it converts Classic webhooks to the format Lumail expects.

### Step 1: Generate your API token

Generate a token exactly as in [Step 1](#step-1-generate-your-api-token) above (or reuse your ClickFunnels token).

### Step 2: Add the webhook to each funnel

Classic webhooks live in each funnel's settings, not in your account settings:

1. Open the funnel in ClickFunnels Classic
2. Go to the **Settings** tab of the funnel
3. Scroll to the bottom and click **Webhooks** (Manage your funnel webhooks)
4. Click **New Webhook** and fill in:
   - **URL**: `https://clickfunnel-v1.lumail.io/{your-token}`
     - Replace `{your-token}` with your API token, including its `lum_` prefix
     - Example: `https://clickfunnel-v1.lumail.io/lum_1a2b3c...`
     - Optional: add tags after the token to tag every subscriber this funnel sends, for example `https://clickfunnel-v1.lumail.io/lum_1a2b3c.../my-course` (separate several tags with commas, write spaces as `%20`: `.../client%20Magic%20Love`)
   - **Event**: `contact_created`
   - **Version**: `1`
   - **Adapter**: `json`
5. Save, then add a second webhook with the same URL for the `purchase_created` event (and a third for `purchase_updated` if you want refunds to sync)

Repeat for every funnel you want to sync. One event per webhook avoids duplicate deliveries.

> Do not paste the ClickFunnels 2.0 URL (`https://lumail.io/api/v1/integrations/clickfunnel/...`) in a Classic funnel: Classic payloads have no `event_type` and Lumail rejects them.

### What syncs from Classic

| Classic event | Lumail behavior |
| --- | --- |
| `contact_created` | Creates or updates the subscriber (email, name, phone) |
| Contact with an unsubscribe date | Marks the subscriber as unsubscribed (never resubscribes them) |
| `purchase_created` / `purchase_updated` with status `paid` | Same as **Invoice Fulfilled**: upserts the buyer and adds the `invoice-fulfilled` tag |
| `purchase_updated` with status `refunded` | Same as **Invoice Refunded**: adds the `invoice-refunded` tag |
| Purchase with another status (failed, pending) | Upserts the buyer as a contact, without an invoice tag |

Classic contacts have no tags, so no ClickFunnels tags are synced: add tags after the token in the URL to tag subscribers per funnel. Tags are added, never removed. On purchase webhooks, these tags are applied only to paid purchases (never to failed, pending or refunded ones). To tag customers only, add the tags to the purchase webhook URLs and not on `contact_created`, which also fires for leads who never pay. Webhooks without an email address are acknowledged and ignored.

## Supported Events

The ClickFunnels integration automatically syncs the following events:

### Contact Created (contact.created)

When a new contact is created in ClickFunnels:

- Creates or updates the subscriber in Lumail
- Syncs all contact fields (first name, last name, phone)
- Applies all tags from ClickFunnels
- Records the event in the subscriber timeline

**Data synced:**

- Email address
- First name and last name (combined into full name)
- Phone number
- All contact tags

### Contact Updated (contact.updated)

When a contact is updated in ClickFunnels:

- Creates or updates the subscriber in Lumail
- Syncs name, phone, and tags
- Records the event in the subscriber timeline

### Contact Identified (contact.identified)

When a visitor is identified by email (typically after submitting a form):

- Creates or updates the subscriber in Lumail
- Syncs name, phone, and tags
- Records the event in the subscriber timeline

> ClickFunnels often emits `contact.updated` and `contact.identified` more than `contact.created`. Lumail treats those as upserts so form opt-ins still land in your list.

### Contact Deleted (contact.deleted)

When a contact is deleted from ClickFunnels:

- Marks the subscriber as unsubscribed in Lumail
- Keeps the subscriber row and send history
- Records the event in the subscriber timeline

### Contact Unsubscribed (contact.unsubscribed)

When a contact unsubscribes in ClickFunnels:

- Marks the subscriber as unsubscribed in Lumail
- Preserves subscriber data for compliance
- Records the event in the subscriber timeline

### Invoice Fulfilled (orders/invoice.fulfilled)

When an order is successfully completed:

- Creates or updates the subscriber with contact information
- Adds the tag **"invoice-fulfilled"**
- Records the event in the subscriber timeline

**Data synced:**

- Email, first name, last name, phone from invoice contact
- The `invoice-fulfilled` tag

> The invoice amount, currency and ID are not stored on the subscriber. Segment on the `invoice-fulfilled` tag, and use the [Events API](/docs/api-reference/api-events-post) if you need the amount in [revenue tracking](/docs/features/revenue-tracking).

### Invoice Refunded (orders/invoice.refunded)

When an order is refunded:

- Creates or updates the subscriber with contact information
- Adds the tag **"invoice-refunded"**
- Records the event in the subscriber timeline

**Data synced:**

- Email, first name, last name, phone from invoice contact
- The `invoice-refunded` tag

> As with fulfilled invoices, the amount and currency are not stored on the subscriber. The `invoice-fulfilled` tag is **not** removed on a refund - a refunded subscriber carries both tags.

## Troubleshooting

### Webhook not working?

1. **Check the webhook URL**: Make sure you replaced `{your-token}` with your actual API token
2. **Verify the token**: Ensure you copied the complete token without extra spaces
3. **Check webhook events**: Make sure you subscribed to the correct events in ClickFunnels
4. **Review logs**: Check your subscriber timeline for any error messages

### Contacts not syncing?

- Verify the webhook is active in ClickFunnels
- Check that the contact has a valid email address
- Ensure the webhook URL is correctly formatted
- Test with a new contact creation to trigger the webhook

### Tags not appearing?

- Tags are automatically created in Lumail if they don't exist
- Check the subscriber's tags section to see all synced tags
- Review the subscriber timeline for tag sync events
- For order events, check for "invoice-fulfilled" and "invoice-refunded" tags

### ClickFunnels Classic webhook not working?

- Make sure the URL starts with `https://clickfunnel-v1.lumail.io/`, not `https://lumail.io/api/...`
- Check the webhook was added on the funnel that actually received the opt-in or purchase
- Pick the `json` adapter and version `1`

### Order events not tracking?

- Make sure you selected the order event types (orders/invoice.fulfilled, orders/invoice.refunded) in the webhook configuration
- Verify that order webhooks are enabled in your ClickFunnels workspace
- Check the subscriber timeline for order-related events

## Related Documentation

- [ClickFunnels Webhooks Documentation](https://support.clickfunnels.com/support/solutions/articles/150000156983-webhooks)
- [Lumail API Reference](/docs/api-reference) - Complete API documentation
- [API Tokens](/docs/api-reference/api-tokens) - Create and manage API tokens
- [Tags API](/docs/api-reference/api-tags-get) - Manage synced tags
- [Subscriber Events](/docs/features/subscriber-events) - View integration events in timeline
- [Revenue Tracking](/docs/features/revenue-tracking) - Record order amounts yourself with the Events API

---

# SystemIO Integration
Source: https://lumail.io/docs/integrations/systemio-integration.md

Automatically sync contacts and tags from [SystemIO](https://systeme.io) to Lumail using webhooks. This integration allows you to centralize your email marketing data and leverage Lumail's advanced segmentation and automation features with your SystemIO audience.

## What is SystemIO?

SystemIO is an all-in-one marketing platform that includes sales funnels, email marketing, course hosting, and webinar capabilities. With this integration, you can sync your SystemIO contacts and activity to Lumail in real-time.

## How It Works

When events occur in SystemIO (new opt-ins, tag changes), SystemIO sends webhook notifications to Lumail. The integration:

1. **Authenticates** the request with the Lumail API token in the webhook URL
2. **Processes** the event (creates subscribers, syncs tags)
3. **Updates** your Lumail database in real-time
4. **Records** all events in the subscriber timeline

## Guided setup (recommended)

Lumail has a step-by-step wizard that generates the token and the webhook URL for you:

**Settings -> Integrations -> Systeme.io**, at `https://lumail.io/orgs/<your-org-slug>/settings/integrations/systemio`.

It gives you the exact values to paste into Systeme.io. The manual steps below do the same thing by hand.

## Setup Instructions

Follow these steps to connect your SystemIO account to Lumail.

### Step 1: Generate an API Token

1. Visit your [organization settings page](https://lumail.io/orgs/default/settings/tokens)

![get your slug](/docs/system-io-slug.png)

2. Click **"Generate Token"** and give it a name like "SystemIO"
3. Copy the generated token - it starts with `lum_` and you'll need it in the next step

![get your token](/docs/system-io-token.png)

### Step 2: Create the Webhook in SystemIO

Now you'll configure the webhook in your SystemIO account.

1. Go to [SystemIO Webhook Settings](https://systeme.io/dashboard/profile/webhook-settings)
2. Click **"Create"** to add a new webhook
3. Fill in the form with these values:
   - **Name**: `Lumail.io`
   - **URL**: `https://lumail.io/api/v1/integrations/systemio/<your-token>`
     - Replace `<your-token>` with the token from Step 1 (e.g., `https://lumail.io/api/v1/integrations/systemio/lum_1a2b3c...`)
   - **Secret**: leave empty, or enter any value - Systeme.io uses it to sign the request

4. Select the events you want to sync:
   - ✅ **Contact Created** - Syncs new opt-ins
   - ✅ **Contact Tag Added** - Syncs tag additions
   - ✅ **Contact Tag Removed** - Syncs tag removals

5. Your filled form should look like this:

![SystemIO webhook form example](/docs/system-io-form.png)

6. Click **"Save"** to activate the webhook

### Step 3: Test the Integration

To verify the integration is working:

1. Add a test contact in SystemIO with some tags
2. Check your Lumail organization's subscribers list
3. The contact should appear with all synced tags
4. Check the subscriber's timeline to see the sync events

## Supported Events

The SystemIO integration automatically syncs the following events:

### Contact Created (CONTACT_CREATED)

When a new contact opts in to your SystemIO list:

- Creates or updates the subscriber in Lumail
- Syncs all contact fields (first name, last name, phone)
- Applies all existing tags from SystemIO
- Records the event in the subscriber timeline

### Contact Tag Added (CONTACT_TAG_ADDED)

When a tag is added to a contact in SystemIO:

- Creates or updates the subscriber in Lumail
- Adds the tag to the subscriber (creates tag if it doesn't exist)
- Records the tag addition in the subscriber timeline

### Contact Tag Removed (CONTACT_TAG_REMOVED)

When a tag is removed from a contact in SystemIO:

- Removes the tag from the subscriber in Lumail
- Records the tag removal in the subscriber timeline
- Keeps the subscriber active

## Security

The webhook URL carries your Lumail API token, which is what authenticates the request and scopes it to your organization. Lumail also requires Systeme.io's `x-webhook-signature` header to be present and well-formed (a 64-character HMAC-SHA256 hex digest) before processing anything.

Treat the webhook URL as a secret: anyone holding it holds a working API token. Revoke the token from **Settings -> API Tokens** to cut the integration off immediately.

> A legacy endpoint, `https://lumail.io/api/orgs/<your-org-slug>/systemio`, is still accepted for existing setups. It identifies your organization by slug rather than by token, so **migrate to the token URL above** and delete the old webhook in Systeme.io.

## Troubleshooting

### Webhook not working?

1. **Check the webhook URL**: Make sure you replaced `<your-token>` with your actual API token, including the `lum_` prefix
2. **Verify the token**: Ensure you copied the complete token without extra spaces
3. **Check webhook events**: Make sure you subscribed to the correct events in SystemIO
4. **Review logs**: Check your subscriber timeline for any error messages

### Contacts not syncing?

- Verify the webhook is active in SystemIO
- Check that the contact has a valid email address
- Ensure the webhook events are properly configured in SystemIO

### Tags not appearing?

- Tags are automatically created in Lumail if they don't exist
- Check the subscriber's tags section to see all synced tags
- Review the subscriber timeline for tag addition events

## Related Documentation

- [SystemIO Webhook Documentation](https://help.systeme.io/article/2930-how-to-use-systeme-ios-webhook-service)
- [SystemIO API Reference](https://systeme.io/api/docs)
- [Lumail API Reference](/docs/api-reference) - Complete API documentation
- [API Tokens](/docs/api-reference/api-tokens) - Create and manage API tokens
- [Tags API](/docs/api-reference/api-tags-get) - Manage synced tags
- [Subscriber Events](/docs/features/subscriber-events) - View integration events in timeline

---

# Integrations
Source: https://lumail.io/docs/integrations.md

Lumail.io integrates seamlessly with popular marketing and sales platforms to streamline your workflow.

## Available Integrations

### [WordPress Plugin](/docs/integrations/wordpress)

Install `[lumail_form]` on a WordPress site. Submissions upsert contacts through `POST /api/v2/subscribers`. The API token stays on the server.

### [ClickFunnels Integration](/docs/integrations/clickfunnel-integration)

Connect your ClickFunnels funnels to automatically add leads to your Lumail.io lists and trigger email sequences.

### [System.io Integration](/docs/integrations/systemio-integration)

Sync your System.io contacts and automate email campaigns based on course enrollments and purchases.

## Coming Soon

We're working on integrations with:

- Shopify
- WooCommerce
- Stripe
- Zapier
- Make (Integromat)

## Custom Integrations

Use our [API](/docs/api-reference) to build custom integrations with any platform.

## Need an Integration?

[Contact us](mailto:support@lumail.io) to request integrations with your favorite tools.

---

# Affiliate Program
Source: https://lumail.io/docs/features/affiliate-program.md

Lumail's affiliate program rewards you when an organization you referred becomes a paying customer:

- **Creator:** €50 after the subscription has remained active for two months.
- **Pro:** €150 after the subscription has remained active for two months.
- **Business:** €200 as soon as the subscription becomes active, with no two-month waiting period.

The referral belongs to your Lumail user account, so you keep the same link across every organization you manage.

## Quick start

1. Open [Account → Affiliate](https://lumail.io/account/affiliate).
2. Enter a personal ref such as `your-name` and select **Save and get link**.
3. Copy the generated URL, for example `https://lumail.io?ref=your-name`.
4. Share that URL directly, or connect the same ref to an organization's **Email sent by Lumail** badge.

Your ref must be unique across Lumail. It can contain up to 32 lowercase letters, numbers, or hyphens. Lumail converts uppercase letters to lowercase automatically.

![Affiliate link setup and reward dashboard](/docs/affiliate/account-dashboard.png)

_Your personal link and all referral, plan, waiting-period, due, and paid metrics live on the same page._

## The ref belongs to the user

An affiliate ref is attached to a **user**, not to an organization. This distinction keeps attribution and payouts stable when you belong to several organizations.

- Your user owns the ref and affiliate dashboard.
- A referred organization stores the user who introduced it.
- An organization's Lumail badge can be connected to one user's ref.
- Rewards are credited to that user, regardless of which Lumail organization they currently have open.

Use **Account → Affiliate** as the canonical place to create, change, copy, or remove your personal ref.

## How attribution works

When someone opens your referral URL, Lumail stores the ref in a secure, HTTP-only cookie for **60 days**. The cookie survives navigation to signup and onboarding.

Attribution follows these rules:

1. Lumail keeps valid referral candidates in first-touch order.
2. When the visitor creates an organization, Lumail credits the first valid affiliate who is not the visitor themselves.
3. Self-referrals are ignored.
4. During onboarding, the new customer can confirm the suggested creator, choose another creator from the searchable list, or say that nobody listed referred them.
5. The final attribution is stored on the created organization.

Creating additional organizations while the referral cookie is still valid can attribute those organizations too. Each organization is tracked separately and can produce at most one reward.

## What the affiliate dashboard shows

Open [Account → Affiliate](https://lumail.io/account/affiliate) to see:

| Metric                       | Meaning                                                                  |
| ---------------------------- | ------------------------------------------------------------------------ |
| **Referred accounts**        | Organizations currently attributed to your user                          |
| **Active Creator accounts**  | Referred organizations with an active eligible Creator subscription      |
| **Active Pro accounts**      | Referred organizations with an active eligible Pro subscription          |
| **Active Business accounts** | Referred organizations with an active Business subscription              |
| **Waiting period**           | Creator and Pro rewards that have not reached the two-month payment date |
| **Rewards paid**             | Conversions whose transfer has been recorded as paid                     |
| **Commission due**           | Eligible unpaid rewards: €50 Creator, €150 Pro, or €200 Business         |
| **Total paid**               | Sum of the affiliate payouts already recorded for you                    |

Counts update from the current organization and subscription state. A trial does not start the Creator or Pro two-month countdown.

### What is not tracked yet

The affiliate dashboard does **not currently show referral-link visits or unique visitors**. Tracking begins in the dashboard once an organization is created and attributed. Account creation, active Creator, Pro, and Business plans, commission due, and completed payouts are tracked.

## Reward rules

The current program is deliberately simple:

- **€50 once** for each referred organization that keeps Creator active for two months.
- **€150 once** for each referred organization that keeps Pro active for two months.
- **€200 once** for each referred organization that activates Business. It is payable immediately.
- Trials do not count. The Creator and Pro countdown starts when the paid subscription becomes active.
- A single organization cannot be paid twice, even if an administrator retries the action.
- The reward is tied to the organization attribution recorded by Lumail.

The dashboard records the amount due, but it does not automatically send money. A Lumail administrator completes the transfer, then records it as paid.

## Reward examples

### Example 1: Creator completes the waiting period

Alex follows `https://lumail.io?ref=maya`, creates an organization, and activates Creator on March 12 after the trial.

- March 12: the organization appears under **Active Creator accounts**.
- March 12 to May 11: the reward appears under **Waiting period**.
- May 12: €50 moves into **Commission due**.
- After the transfer: the admin marks it paid, **Rewards paid** increases by one, and **Total paid** increases by €50.

### Example 2: Business is immediate

Sam follows Maya's link and activates Business on June 4.

- June 4: the organization appears under **Active Business accounts**.
- The €200 reward appears in **Commission due** immediately.
- There is no two-month waiting period.

### Example 3: a trial does not qualify

Taylor creates an attributed organization and starts a Creator trial, but cancels before paying. The organization is still a referred account, but no reward becomes due and the two-month countdown never starts.

## Turn email badges into affiliate links

The **Email sent by Lumail** badge can use your ref, turning every eligible badge click into a referral visit.

From the organization:

1. Open **Settings → Configuration**.
2. Find **Affiliate Ref**.
3. Enter your personal ref and select **Save ref**.

Future campaign, workflow, and confirmation email badges from that organization link to `https://lumail.io?ref=your-ref`.

![Affiliate ref connected to an organization badge](/docs/affiliate/organization-badge-ref.png)

_The organization setting connects future “Email sent by Lumail” badge clicks to the selected user's personal ref._

Plan behavior:

- **Free organizations:** the Lumail badge is always enabled. Adding a ref makes it your affiliate link.
- **Paid organizations:** the organization can enable or disable the badge. When enabled and connected to your ref, it becomes your affiliate link too.

Clearing **Affiliate Ref** in organization settings disconnects that organization's future badges. It does not remove the personal ref from your user account. To remove the ref everywhere, clear it from **Account → Affiliate** as well.

## Onboarding confirmation

The optional **How did you hear about Lumail?** step protects attribution quality.

- If a valid referral cookie exists, Lumail asks the customer to confirm the creator.
- If they choose **Creator or newsletter**, they can search and select a creator in the combobox.
- Other answers such as YouTube, X, Google, word of mouth, or another source are stored as declared acquisition sources.
- The customer can skip the question.

This confirmation lets a customer correct a stale or incorrect referral before onboarding finishes.

![Referral creator confirmation during onboarding](/docs/affiliate/onboarding-creator.png)

_A referred customer can confirm the suggested creator or choose another source._

## How payouts are recorded

Lumail administrators manage conversions from **Admin → Affiliates**. The page shows creators, attributed organizations, active Creator, Pro, and Business conversions, waiting periods, completed rewards, and outstanding balances.

After the external transfer is complete, an administrator selects **Mark paid** for the eligible organization and confirms the action. Lumail then creates an immutable payout record containing the amount, EUR currency, organization, affiliate user, administrator, and payment date.

Creator and Pro display the exact eligibility date until two months have passed. Business can be marked paid immediately. The action is idempotent: repeating it does not create a second payout. Organizations without affiliate attribution or without an eligible active subscription cannot be marked paid.

![Affiliate payout administration](/docs/affiliate/admin-payouts.png)

_The admin sees each plan, scheduled eligibility date, amount due, and immutable paid state before confirming a transfer._

## Common questions

### Can two users use the same ref?

No. Refs are unique across Lumail.

### Can I change my ref?

Yes. Change it from **Account → Affiliate**. Previously shared URLs using the old ref stop resolving to your account, so update links you still control.

### Can I refer myself?

No. Lumail ignores self-referrals.

### Does a free signup earn a reward?

No. The organization is counted as a referred account, but the reward becomes due only after an eligible Creator, Pro, or Business subscription is active.

### Does a Creator trial earn €50?

No. Trials do not count.

### When does Creator or Pro become payable?

Exactly two calendar months after the paid subscription becomes active. If Creator activates on January 31, the eligibility date is March 31; shorter destination months are clamped to their final day.

### Does Business earn a reward?

Yes. An active Business conversion earns €200 immediately, without the two-month waiting period.

### Why is a referred account missing?

The organization may have been created after the 60-day cookie expired, the ref may have been invalid, the visitor may have rejected or corrected the creator during onboarding, or the referral may have been a self-referral.

## Related documentation

- [Account Verification](/docs/features/account-verification) - How a new organization unlocks sending
- [Email Sending Queue](/docs/features/email-sending-queue) - How plan level affects sending capacity
- [Revenue Tracking](/docs/features/revenue-tracking) - Track your own subscribers' purchases inside Lumail

---

# Account Verification
Source: https://lumail.io/docs/features/account-verification.md

New accounts start in a limited mode: you can build campaigns, import subscribers, and send test emails to your own address, but not to your audience yet. This screening protects the deliverability of every sender on Lumail.

Unlocking is automatic — no form, no waiting for a human.

## How to unlock sending

Verify your own sending domain. The moment your DNS records verify, your account is approved automatically and sending unlocks:

1. Add your domain in **Domains** (or during onboarding).
2. Configure the DNS records shown on the domain page.
3. When the records verify, sending unlocks instantly — you'll receive a notification. If it does not, open **Settings → Verification** and click **Check process**.

You can follow your status at any time in **Settings → Verification**, which shows the checklist and your sender score.

Automatic verification includes **3,000 emails per month** on the Free plan. A paid plan lifts that cap automatically — your plan's sending limit then applies.

## Staying unlocked: your sender score

Your sender score (0–100) reflects the last 30 days of sending: bounces, spam complaints, opens, and clicks. It is visible in **Settings → Verification** and as **Sender health** on **Settings → Deliverability**. Same formula.

As long as your score stays healthy, nothing changes. If your sending degrades — a spam complaint, or a sustained pattern of high bounces and low engagement — automatic verification can be paused and sending restricted until you complete additional verification. You'll be notified in-app with the exact next step if that happens.

To keep your score healthy:

- Send to people who asked to hear from you.
- Clean your list — remove addresses that bounce.
- Use [double opt-in](/docs/tutorials/enable-double-opt-in) for new signups.

## Additional verification

Most accounts are verified automatically as soon as their domain setup is complete. Additional verification only applies when:

- Your sending was restricted after a deliverability check.
- Our team requested more information about your account.

In these cases, the form in **Settings → Verification** asks for your website, a social profile, your list size, and how you plan to use Lumail.

---

# Language
Source: https://lumail.io/docs/features/language.md

Lumail has two language surfaces. They are independent.

1. **Recipient pages** — unsubscribe, confirmation, subscribe, and survey answers. These follow **Settings → Language**.
2. **Email content** — subject, body, footer sentence, and the visible **Unsubscribe** label. You write these. Changing the organization language does not rewrite them.

The dashboard (everything under `/orgs`) stays in English.

## Change the language of unsubscribe and confirmation pages

1. Open **Settings** in the sidebar (the organization page).
2. Find the **Language** card.
3. Pick **English**, **Français**, **Español**, or **Deutsch**.
4. Save.

That language is used for every page a recipient opens from your emails:

- unsubscribe confirmation
- subscription confirmation
- tag subscribe / unsubscribe success
- survey answer pages, including default NPS captions

It does **not** change the Lumail app, and it does **not** translate campaign or snippet copy.

## Translate the email footer and Unsubscribe link

New campaigns insert an English footer. Typical default copy:

- `You receive this email because you opt-in in our website.`
- a bold **Unsubscribe** link pointing at `{{unsubscribeUrl}}`

Click the text in the editor and rewrite it. The URL must stay `{{unsubscribeUrl}}`. Only the visible label is yours.

| Language | Footer sentence | Link label |
| -------- | --------------- | ---------- |
| French | Vous recevez cet email car vous êtes inscrit à notre liste. | Se désabonner |
| Spanish | Recibes este email porque te suscribiste a nuestra lista. | Darme de baja |
| German | Du erhältst diese E-Mail, weil du dich für unsere Liste angemeldet hast. | Abmelden |

If the default sentence is wrong for your list (artists who did not opt in on a website, B2B outreach, a purchased list you already have consent for), replace it with whatever is true for that send.

## Reuse a translated footer with a snippet

To apply the same footer on every campaign:

1. Open **Campaigns → Snippets** in the sidebar.
2. Create a snippet with the translated footer and unsubscribe link.
3. Insert that snippet in campaigns instead of the default English footer.

Editing the snippet updates every campaign that uses it.

## What language does not cover

| Surface | Who controls it |
| --- | --- |
| Dashboard (campaigns, settings, editor) | English only |
| Unsubscribe / confirm / survey pages | **Settings → Language** |
| Campaign subject, body, buttons | You, in the editor |
| Default footer + "Unsubscribe" label | You (English template until you edit it or a snippet) |
| Organization name and address in the footer | Settings (`{{org-name}}`, `{{org-adresse}}`) |

## Related

- [Variables](/docs/features/variables) — `{{unsubscribeUrl}}`, `{{org-name}}`, `{{org-adresse}}`
- [Surveys](/docs/features/surveys) — NPS captions fall back to the organization language
- [Content Deliverability](/docs/features/content-deliverability) — unsubscribe link required

---

# Variables
Source: https://lumail.io/docs/features/variables.md

Variables allow you to personalize your email content and subject lines for each subscriber.

## Syntax

Use double curly braces to insert variables:

```
{{variableName}}
```

### With Default Value

Add a fallback value when the variable is empty:

```
{{variableName | 'Default Value'}}
```

Or with double pipe:

```
{{variableName || 'Default Value'}}
```

## Examples

### Subject Line

```
{{name | 'Hey'}}, voici ton guide LinkedIn
```

**Result:**

- If subscriber has name "Marie" → `Marie, voici ton guide LinkedIn`
- If subscriber has no name → `Hey, voici ton guide LinkedIn`

### Email Content

```
Hello {{name | 'there'}},

Your email is {{email}}.
```

## Available Variables

### Built-in Variables

| Variable             | Description                                        |
| -------------------- | -------------------------------------------------- |
| `{{name}}`           | Subscriber's name                                  |
| `{{email}}`          | Subscriber's email address                         |
| `{{phone}}`          | Subscriber's phone number                          |
| `{{ipAddress}}`      | Subscriber's IP address (captured at signup)       |
| `{{country}}`        | Subscriber's country code (e.g., "US", "FR", "CH") |
| `{{unsubscribeUrl}}` | Unsubscribe link                                   |
| `{{onlineUrl}}`      | View in browser link                               |

### Organization Variables

| Variable          | Description           |
| ----------------- | --------------------- |
| `{{org-name}}`    | Organization name     |
| `{{org-adresse}}` | Organization address  |
| `{{org-logo}}`    | Organization logo URL |

### Global Organization Variables

In **Settings → Configuration → Variables**, add a shared value such as
`number_member_editing_ai` = `1250`. Insert it with `@` in the editor (the globe
icon identifies global variables), or use `{{global-number_member_editing_ai}}`
in subjects, previews, HTML and links. Rich email content uses a variable node
with `attrs.id: "global-number_member_editing_ai"`.

Global variables belong to the organization, so every recipient sees the same
value. Subscriber fields and per-email payloads cannot override them. An inline
fallback is used only if the global variable has been deleted or is missing.
The `global-` prefix is reserved: subscriber fields cannot be created or renamed
with it, including through the API or imports. If an older subscriber field
already uses a global key, rename that field before creating the global variable.
Values are read when an email is prepared: previously prepared or sent emails
keep their values. These are stored values, not automatically calculated counts.

Names start with a lowercase letter and contain up to 64 lowercase letters,
digits, underscores or hyphens. An organization can store up to 100 variables,
with at most 2,000 characters per value. Use `"0"` for zero; blank values delete
the variable.

API and AI agents use the same organization-scoped tools (API token permission:
`settings`):

```http
POST /api/v2/tools/get_organization_variables
Content-Type: application/json
Authorization: Bearer YOUR_API_TOKEN

{}
```

```http
POST /api/v2/tools/update_organization_variables
Content-Type: application/json
Authorization: Bearer YOUR_API_TOKEN

{"changes":{"global-number_member_editing_ai":"1250"}}
```

Only supplied keys change. Send `null` to delete a key. The `name` key is reserved
for the existing subscriber-name fallback. Use `get_available_variables` to
discover global variables alongside built-ins and subscriber fields.

### Subscriber Custom Fields

Any custom field you create for subscribers can be used as a variable:

```
{{customFieldName | 'default'}}
```

## Using Variables in Editor

1. Type `@` in the editor to open the variable menu
2. Select or type your variable name
3. Click on the variable to edit the default value

## Tag Action Links

Tag action links use variable-like syntax only in a link's URL; they are not content or subject-line variables.

```
{{addTag:interested}}
{{removeTag:promo}}
```

For the editor flow, delivery restrictions, success page, and workflow use cases, see [Tag Action Links](/docs/features/tag-action-links).

## Best Practices

- Always provide default values for optional fields like `name`
- Use descriptive default values that make sense in context
- Test your emails with subscribers who have missing data

## Related Documentation

- [Tag Action Links](/docs/features/tag-action-links) - Let subscribers add or remove tags by clicking a link
- [Send Email API](/docs/api-reference/api-emails-send) - Use variables in transactional emails
- [Workflows](/docs/workflows) - Use variables in workflow email steps
- [Language](/docs/features/language) - Recipient-page language vs email footer copy
- [Content Deliverability](/docs/features/content-deliverability) - Required variables like `{{unsubscribeUrl}}`
- [Create Subscriber](/docs/api-reference/api-subscribers-post) - Add custom fields for personalization

---

# Tag Action Links
Source: https://lumail.io/docs/features/tag-action-links.md

Tag action links let subscribers update their preferences with one click. Use them to capture interests, opt out of a topic, or start a workflow, without a login.

> **Before you use Add tag:** Clicking an add-tag link also resubscribes an unsubscribed subscriber. There is no confirmation step; the click applies the action immediately.

## Insert In The Editor

Tag action links work in **campaigns and workflow emails**, not transactional emails. Put the variable in the **URL field** of a link or button, never in the subject line or as plain body text.

1. Select the text you want to turn into a link, or select a button
2. Open the link field with the link icon or `Cmd+K`
3. In the URL input, type `@` to open the variable menu
4. Select **Add tag** or **Remove tag**, or keep typing:
   - `@addTag` / `@removeTag` opens the tag submenu
   - `@vip` surfaces matching add/remove actions for that tag
5. Choose an existing tag, or type a new tag name and press Enter

You can insert a tag that does not exist yet. **Add tag** creates it on the first click. **Remove tag** is a no-op until that tag exists on the subscriber.

<details>
<summary>See the editor walkthrough</summary>

![Type @tag to surface Add tag and Remove tag](/docs/tag-action-links-menu.png)

![Choose a tag from the Add tag submenu](/docs/tag-action-links-submenu.png)

![Tag action variable inserted as the link URL](/docs/tag-action-links-url.png)

![Linked text using a tag action URL in the editor](/docs/tag-action-links-editor.png)

</details>

## Syntax

Use one of these variables as the complete URL. Separate multiple tag names with commas.

| Action               | URL to paste                     |
| -------------------- | -------------------------------- |
| Add a tag            | `{{addTag:vip}}`                 |
| Remove a tag         | `{{removeTag:promo}}`            |
| Add multiple tags    | `{{addTag:vip,early-access}}`    |
| Remove multiple tags | `{{removeTag:promo,newsletter}}` |

Do not add `https://`. Lumail generates a complete subscriber-specific URL when it sends the email.

## Examples

**Interest capture**

```
Want more content about AI? [Get AI updates]({{addTag:interested-ai}})
```

The link text is the promise. The URL is only `{{addTag:interested-ai}}`.

**Soft opt-out**

Leave one topic without a full unsubscribe:

```
[Stop promotional emails]({{removeTag:promo}})
```

**Multiple tags at once**

```
[Join the VIP list]({{addTag:vip,early-access}})
```

**HTML**

If you are writing HTML (API, snippet, or custom block), put the variable in `href` only:

```html
<a href="{{addTag:interested-ai}}">Get AI updates</a>
<a href="{{removeTag:promo}}">Stop promotional emails</a>
<a href="{{addTag:vip,early-access}}">Join the VIP list</a>
```

**Preference center in the email**

Put choices directly in the email instead of a separate settings page:

```
Keep weekly tips: [Yes, keep them]({{addTag:weekly-tips}})
Leave the promo stream: [Unsubscribe from promos]({{removeTag:promo}})
```

**Workflow trigger**

Use a click as the entry signal for a sequence:

1. Subscriber clicks `{{addTag:interested}}`
2. A workflow starts when that tag is added
3. The follow-up sequence begins automatically

See [Workflows](/docs/workflows) and [Action Step](/docs/workflows/workflow-action-step) for tag-based automation.

## How It Works

1. You use a tag action variable as the **URL** of a link or button
2. When the email is sent, Lumail replaces it with a unique per-subscriber URL
3. On click, tags are applied immediately
4. The subscriber lands on a success page with the requested tags shown as badges

## What The Subscriber Sees

After the click, the subscriber sees a success page that:

- Uses your organization branding
- Confirms the action completed
- Shows the tags requested by the link as badges

## Constraints

- **Add tag** creates a tag on the first click if it does not exist, and resubscribes an unsubscribed subscriber.
- **Remove tag** only removes existing tags. It has no effect if the subscriber does not have the tag.
- Removing a topic tag is not a global unsubscribe. Use that tag in your audience targeting to stop emails for that topic.
- You can combine tag action links with conditional content (`showIf`) to show different choices to different segments.

## Best Practices

Before sending, test the complete click path and check the success page and subscriber profile.

- Make the link text describe the result, such as **Get AI updates** or **Stop promotional emails**.
- Prefer one clear action per link and short, readable tag names (`interested-ai`, `promo`, `vip`).
- Reuse existing organization tags when possible to keep the editor menu easy to scan.
- Check that resubscribing is intended wherever you use an add-tag link.

## Related Documentation

- [Variables](/docs/features/variables) - Personalize email content and subject lines
- [Workflows](/docs/workflows) - Trigger sequences from tag changes
- [Action Step](/docs/workflows/workflow-action-step) - Add or remove tags inside a workflow
- [Workflow Groups](/docs/workflows/workflow-groups) - Chain tag handoffs without overlapping enrollments
- [Subscriber Events](/docs/features/subscriber-events) - Review tag add/remove history
- [Tags API](/docs/api-reference/api-tags-get) - Create and manage tags programmatically
- [Add Tags to Subscriber](/docs/api-reference/api-subscriber-tags-post) - Add tags via API
- [Remove Tags from Subscriber](/docs/api-reference/api-subscriber-tags-delete) - Remove tags via API
- [Content Deliverability](/docs/features/content-deliverability) - Keep campaign content inbox-ready

---

# Surveys
Source: https://lumail.io/docs/features/surveys.md

A survey block turns a campaign into a one-question poll. Recipients answer by
clicking an answer directly in their inbox — there is nothing to load, no form
to fill in, and no account to create. Answers land on the campaign's **Survey**
tab, on each subscriber's timeline, and in subscriber filters.

See every style with a screenshot on the [Surveys gallery](/docs/survey).

## Adding a survey

In the campaign editor, open the slash menu (`/`) and pick **Survey**. Each
block holds one question and its answers. One campaign can carry several survey
blocks; each one is tracked separately.

| Style    | What the recipient sees                                            |
| -------- | ------------------------------------------------------------------ |
| Buttons  | Answers stacked as equal-width buttons, labels left-aligned        |
| Yes / No | Two stacked equal-width answers. Switching to it rewrites the first two options as Yes / No |
| Emoji    | Stacked answers prefixed with an emoji you choose                  |
| NPS      | A fixed 0–10 scale, left-aligned with the question                 |

## Block settings

Click the block to open the toolbar.

| Setting           | Values / behavior                                                                 | Default       |
| ----------------- | --------------------------------------------------------------------------------- | ------------- |
| Style             | `buttons` · `yes_no` · `nps` · `emoji`                                            | `buttons`     |
| Comment           | `allowComment` — free-text note after the vote, max 1000 characters               | `false`       |
| Alignment         | `left` · `center` · `right`. Moves the question and the NPS row. Stacked answers stay full width | `left` |
| Button color      | Fill of each answer. Falls back to the theme button color                         | `#000000`     |
| Text color        | Label color inside each answer                                                    | `#ffffff`     |
| Show if           | Tag conditions (`showIfConditions`). Same visibility rules as a Section           | always shown  |
| NPS low caption   | `npsLowLabel` — under 0. `null` uses the recipient's language                     | `null`        |
| NPS high caption  | `npsHighLabel` — under 10. Same fallback                                          | `null`        |

NPS options are virtual (`nps_0` … `nps_10`) and are not stored on the block.
Yes / No only sends the first two options; extra options stay on the block if
you switch back to Buttons.

Colors, alignment and Show if are presentation. Changing them does **not**
create a new survey version. Changing the question, style, answers, comment
toggle or NPS captions does.

## Limits

| Thing            | Limit                                                         |
| ---------------- | ------------------------------------------------------------- |
| Answers          | 2–8 (`buttons`, `yes_no`, `emoji`). NPS is fixed at 0–10      |
| Question         | 1–300 characters, required to send                            |
| Answer label     | 120 characters                                                |
| NPS captions     | 60 characters each                                            |
| Comment          | 1000 characters                                               |
| `surveyId`       | `svy_` + 10 alphanumeric characters, unique in the document   |
| Option `id`      | Stable string, max 64 characters. Never reuse across options  |

The editor can hold an incomplete survey. Sending (schedule, send now, API,
workflow publish) rejects a block with no question, fewer than two labelled
answers, a duplicated id, or merge fields in immutable text.

## TipTap node

Campaigns, workflow emails and the [Tiptap send API](/docs/api-reference/api-emails-tiptap)
all use the same node. Insert it as a top-level block, or inside a section /
column — never inside a `repeat`.

```json
{
  "type": "survey",
  "attrs": {
    "surveyId": "svy_abcdefghij",
    "question": "What should we write about next?",
    "style": "buttons",
    "allowComment": false,
    "alignment": "left",
    "buttonColor": "#000000",
    "textColor": "#ffffff",
    "npsLowLabel": null,
    "npsHighLabel": null,
    "options": [
      { "id": "opt_a1b2c3d4e5", "label": "Talk about AI", "emoji": "🤖" },
      { "id": "opt_f6g7h8i9j0", "label": "No AI please", "emoji": "🙅" }
    ]
  }
}
```

| Attr              | Required | Notes                                                                 |
| ----------------- | -------- | --------------------------------------------------------------------- |
| `surveyId`        | yes      | Mint once. Copying a campaign regenerates it so results do not pool   |
| `question`        | yes      | No `{{variables}}`                                                    |
| `style`           | no       | `buttons` (default), `yes_no`, `nps`, `emoji`                         |
| `options`         | yes*     | 2–8 items with unique `id` + `label`. Ignored for `nps`               |
| `options[].emoji` | no       | Printed only when `style` is `emoji`                                  |
| `allowComment`    | no       | `false` by default                                                    |
| `npsLowLabel`     | no       | Author caption, or `null` to translate                                |
| `npsHighLabel`    | no       | Same                                                                  |
| `alignment`       | no       | `left` (default), `center`, `right`                                   |
| `buttonColor`     | no       | Hex. `null` uses the theme                                            |
| `textColor`       | no       | Hex. `null` is white                                                  |
| `showIfConditions`| no       | Same shape as a Section                                               |

Do not put `{{name}}` (or any merge field) in the question, labels or NPS
captions. Results need one immutable label shared by every recipient.

## What the recipient sees

Clicking an answer opens a small confirmation page and records the answer.
The question and the answers sit in a bordered card — the same chrome as the
editor — so the poll reads as one block in the inbox.
Opening that page never records anything on its own — the answer is only saved
once the page confirms it, so a mail scanner that pre-fetches every link in
your email does not pollute your results.

After answering, the recipient can change their mind: picking another answer
replaces the previous one. Only their latest answer counts. They can add,
edit or clear a comment (empty submit clears it) even without JavaScript.

Test sends use an inert `/s/test` link that records nothing.

## Reading the results

Open the campaign, then the **Survey** tab. For every survey block you get:

- the number of answers, and what share of the delivered emails that is;
- one bar per answer, with its count and percentage;
- for NPS, the score (`% promoters − % detractors`), where 9–10 are promoters,
  7–8 passives and 0–6 detractors;
- the comments, newest first, each next to the answer it came with.

Answers to an option you have since deleted are still counted and shown, marked
as removed. Nothing is ever silently dropped from your totals.

## Automated answers

Some answers are not human — link scanners in corporate mail gateways click
every URL in a message. Lumail flags an answer as automated when it comes from
a known bot user agent, when it arrives implausibly soon after the email was
sent, or when one IP address exceeds 120 answers per minute.

Flagged answers are stored but excluded from every count, percentage and NPS
score, and the results page tells you how many were excluded. Bot-only rows
count as unanswered for filters.

## Filtering subscribers on an answer

In **Subscribers**, add a **Survey** filter and pick a survey.

| Operator             | Matches                                              |
| -------------------- | ---------------------------------------------------- |
| `ANSWERED`           | Anyone who answered this survey                      |
| `NOT_ANSWERED`       | Anyone who did not (including bot-only rows)         |
| `ANSWERED_WITH`      | Current answer is this option (`optionId` required)  |
| `NOT_ANSWERED_WITH`  | Current answer is not this option                    |

Answer identity is stable: renaming an answer, reordering the list, or editing
the question keeps every filter and every recorded answer pointing at the same
option. Deleting an option makes `ANSWERED_WITH` stop matching it.

Filter payload (audience filters, campaign recipient filters, tools):

```json
{
  "type": "SURVEY",
  "field": "survey",
  "surveyId": "svy_abcdefghij",
  "surveyName": "What should we write about next?",
  "operator": "ANSWERED_WITH",
  "optionId": "opt_a1b2c3d4e5",
  "optionLabel": "Talk about AI"
}
```

`surveyName` and `optionLabel` are display-only. Matching uses `surveyId` and
`optionId`.

## Automations

A survey answer emits two things:

- a `SURVEY_ANSWERED` entry on the subscriber's timeline, carrying the question
  and the answer as the recipient saw them;
- a `subscriber.survey.answered` workflow trigger, so a workflow can react to a
  specific answer.

Both fire on the **first** genuine answer only. Changing an answer updates the
result but never re-enrolls the subscriber into a workflow they already went
through.

### Workflow trigger params

On a trigger that listens to `subscriber.survey.answered`:

| Event filter | Required | Effect                                                                 |
| ------------ | -------- | ---------------------------------------------------------------------- |
| *(none)*     | —        | Starts on **every** survey answer in the organization, including surveys created later. Do not leave this empty unless that is the intent |
| `surveyId`   | no       | Restrict to one survey                                                 |
| `optionId`   | no       | Restrict to one answer. Requires `surveyId`                            |

```json
{
  "surveyId": "svy_abcdefghij",
  "optionId": "opt_a1b2c3d4e5"
}
```

Pin the survey. Duplicating a campaign mints a fresh `surveyId` but keeps
option ids, so an unscoped trigger on a copied campaign would not enrol into
the original's workflow — unless you forgot `surveyId` and matched every
survey in the org.

See [Workflow Triggers](/docs/workflows/workflow-triggers).

## Editing a survey after sending

Once a campaign is sent, its survey block is frozen: the question and the
answers can no longer be changed. Everything else about the campaign — its
name, its subject, the rest of its content — stays editable. Recolor and
realign still work; they do not change recorded answers.

To ask a different question, duplicate the campaign; the copy starts a fresh
survey with its own results (`surveyId` is regenerated, option ids are kept).

In a workflow, where the same email keeps going out over time, editing the
survey creates a new version. Older answers stay attached to the version the
recipient actually received, and the results page merges them by answer.

## Related

- [Surveys gallery](/docs/survey) — screenshots of all four styles
- [Send Email in Tiptap](/docs/api-reference/api-emails-tiptap) — `survey` node
- [Workflow Triggers](/docs/workflows/workflow-triggers) — `subscriber.survey.answered`
- [Subscriber Events](/docs/features/subscriber-events) — timeline
- [Language](/docs/features/language) — default NPS captions follow the organization language

---

# Capture pages with AI agents
Source: https://lumail.io/docs/features/capture-pages-agents.md

A capture page is a signup landing page hosted by Lumail. Every signup lands in
your audience with the tags you chose. You can build one in the dashboard AI
builder, or ask your own agent to do it through the Lumail skill, the CLI or the
MCP server. All three use the same tools, so a page an agent creates opens in
the builder exactly like one you made there.

## Ask your agent

With the [Lumail skill](/docs/ai-integration/skills-cli) or the
[MCP server](/docs/ai-integration/mcp-claude) installed, describe the page:

<AgentPrompt
  label="Prompt"
  prompt="Build a Lumail capture page for my free SEO checklist for indie hackers. Dark theme, tag signups with seo-checklist. Leave it as a draft and send me the preview link."
/>

The agent reads the page rules with `get_skill({ type: "capture_page" })`,
writes the page, validates it, saves it as a **draft** and gives you two
links: a **preview link** that renders the draft exactly like the live page
(anyone with it can view it for 2 hours, and it records no views or signups),
and the **builder link**, where you preview it on desktop, tablet and mobile
and keep refining it with the in-app AI. Nothing is public until you ask the agent
to publish, or press **Publish** in the builder.

## How a page is made

A page is two files:

| File        | What it holds                                                                        |
| ----------- | ------------------------------------------------------------------------------------ |
| `page.tsx`  | One React component (`export default function App()`), Tailwind classes, no imports. |
| `theme.css` | The `:root` color and font tokens, plus optional `@keyframes`.                       |

Signup forms use the built-in `EmailProvider`, `EmailInput` and `EmailSubmit`
components. Lumail rejects code that fetches URLs, touches `window` or
`document`, uses hooks or event handlers, or imports packages, so a page can
only collect emails for your list.

## From the terminal

The CLI keeps a page in a local folder so an agent (or you) can edit it with
normal file tools:

```bash
npm install -g lumail
lumail auth login

# Start from a template; writes page.tsx, theme.css and capture.json
lumail captures templates
lumail captures create --name "SEO checklist" --template lead-magnet \
  --tags seo-checklist --dir ./capture

# Edit ./capture/page.tsx, then check and save it as the draft
lumail captures validate --dir ./capture
lumail captures push --dir ./capture   # prints a fresh preview link

# Go live when you are happy with the preview
lumail captures publish --dir ./capture
```

If `./capture/page.tsx` already exists, `create --dir` uploads it instead of
starting from the template. `push`, `publish` and `versions` read the page ID
from `capture.json`, so they work without an ID inside that folder.

| Command                            | What it does                                                   |
| ---------------------------------- | -------------------------------------------------------------- |
| `captures list`                    | Pages with status, views, signups, conversion rate and URL.    |
| `captures get <id> [--code]`       | One page, its links and optionally its draft code.             |
| `captures pull <id> --dir <dir>`   | Downloads an existing page into a folder to edit it.           |
| `captures settings <id>`           | Renames it, changes its slug, SEO title and description, tags. |
| `captures versions [id]`           | Published versions and which one is live.                      |
| `captures rollback <id> <version>` | Makes an older version live again and restores it as draft.    |
| `captures delete <id>`             | Deletes the page after a confirmation code.                    |

Add `--json` to any command for machine-readable output.

## Drafts, publishing and versions

- The draft is what the builder shows and what agents edit. Saving a draft
  never changes the live page.
- Publishing turns the current draft into a new version (`v1`, `v2`...) and
  makes it live at the page URL.
- Rolling back makes an older version live and loads it back as the draft.
- Names never clash: creating a second "SEO checklist" gives you
  "SEO checklist (2)".

## Tools reference

Agents call these tools through MCP, the
[Tools API](/docs/ai-integration/tools-api) or the CLI. They need an API token
or MCP connection with the **campaigns** permission.

| Tool                                                  | Purpose                                             |
| ----------------------------------------------------- | --------------------------------------------------- |
| `list_capture_pages`, `get_capture_page`              | Read pages, stats, links and draft code.            |
| `list_capture_page_templates`                         | Built-in designs, optionally with their code.       |
| `validate_capture_page_code`                          | Checks code without saving anything.                |
| `create_capture_page`                                 | Creates a draft from code, a template, or empty.    |
| `update_capture_page`, `update_capture_page_settings` | Saves draft code, or changes name, slug, SEO, tags. |
| `publish_capture_page`                                | Publishes the draft as a new live version.          |
| `list_capture_page_versions`, `rollback_capture_page` | Version history and rollback.                       |
| `delete_capture_page`                                 | Deletes a page (asks for a confirmation code).      |

See the [full tools reference](/docs/ai-integration/tools-reference) for every
input and output field.

---

# Content Deliverability Checker
Source: https://lumail.io/docs/features/content-deliverability.md

You write. The checker watches. Three seconds after you stop typing, the **Content checks** number in the toolbar updates — 0 to 100.

That number is content hygiene only. It is not sender health, and it is not the dashboard campaign delivery score. Think of it as a pre-flight: did this email forget its unsubscribe link, shout in ALL CAPS, or lean on "click here"?

<DocsPhoto
  src="/docs/content-deliverability/hero.png"
  alt="A cream envelope sliding into a dark steel inbox tray"
  caption="The job is simple: land in the inbox, not the junk folder."
/>

Click the score. You get the eleven checks, each with points and a one-line reason.

<DeliverabilityScorePreview />

## How it works

It runs on its own. No button to mash. After a 3-second pause it scores eleven things that push an email toward inbox or spam.

<DeliverabilityCheckGrid />

## The eleven checks

### 1. Unsubscribe link — 25 points

Required by law (CAN-SPAM, GDPR) and expected by every inbox.

- Use the `{{unsubscribeUrl}}` variable
- Type `/` and pick **Unsubscribe Link**
- Put it in the footer

No link, no points. This one is the heaviest check on purpose.

### 2. Physical address — 10 points

CAN-SPAM wants a real mailing address. Three ways to pass:

1. `@org-adresse` (the easy one)
2. Your address written in the footer
3. Any address that matches a normal US, EU, or PO Box pattern

Examples it understands:

- US: `123 Main St, City, CA 90210`
- EU / French: `123 rue de la Paix, 75001 Paris`
- PO Box: `P.O. Box 123` or `Boîte Postale 123`

### 3. Reason for sending — 10 points

Gmail and Yahoo want a sentence that says **why this person is on the list**. Language does not matter. The checker looks for “you receive this because…” / “tu reçois ce mail car…” and the same shape in ES, IT, DE, PT, plus a signed-up / subscribed line.

Examples that pass:

- `You're receiving this email because you signed up at melvynx.com`
- `Tu reçois ce mail car tu t'es inscrit sur melvynx.com`
- `Estás recibiendo este correo porque te has suscrito`

The default “Don't want to receive these emails?” unsubscribe line does **not** count.

See [Reason for Sending](/docs/tutorials/reason-for-sending) for a video walkthrough, copy-ready examples, and guidance for audiences with multiple signup sources.

### 4. Subject line length — 10 points

Sweet spot: **20–70 characters**. First 30 matter most on mobile. Put the point up front. Skip ALL CAPS.

| Length      | Score | Why                                 |
| ----------- | ----- | ----------------------------------- |
| 20–70 chars | 10/10 | Fits. Readable.                     |
| 1–19 chars  | 5/10  | Looks thin, sometimes spammy        |
| 71+ chars   | 5/10  | Gets cut on phones                  |
| Empty       | 0/10  | You forgot the subject              |

### 5. Image-to-text ratio — 10 points

All-image emails get blocked. Rule of thumb: **one image per 200 words**. Add alt text. Let the words do some work.

### 6. Spam trigger words — 10 points

The checker knows **270+** phrases in English, French, Spanish, and Italian. Each hit costs 3 points, floor at 0.

| Category  | The usual suspects                                             |
| --------- | -------------------------------------------------------------- |
| Urgency   | "act now", "limited time", "expires today", "urgent"           |
| Financial | "free money", "earn cash", "double your money", "get rich"     |
| Phishing  | "click here", "verify your account", "confirm your identity"   |
| Scams     | "congratulations", "you're a winner", "you have been selected" |
| Health    | "weight loss", "no prescription", "miracle cure"               |

Pro move: run **Unspam** and let the AI swap the risky phrases.

### 7. Link count — 5 points

| Links | Score | Note               |
| ----- | ----- | ------------------ |
| 0–5   | 5/5   | Fine               |
| 6+    | 2/5   | Cut a few          |

One to three real CTAs beat a pile of "also click this".

### 8. No shortened URLs — 5 points

`bit.ly`, `tinyurl`, `t.co`, `goo.gl`, `ow.ly`, `is.gd`, `buff.ly`, `adf.ly`, `tiny.cc`, `cutt.ly`, `rb.gy` and friends look like hidden links. Spam filters treat them that way.

Use your own domain. Lumail already tracks clicks.

### 9. No ALL CAPS abuse — 5 points

Max two words with 4+ uppercase letters.

Allowed: API, URL, HTML, CEO, ROI, AWS, LLC, INC, USA, FAQ, and brand names like CLAUDE.

Flagged: FREE, SALE, URGENT, AMAZING, or a line like "GET YOUR FREE BONUS NOW".

### 10. Text length — 5 points

Very short emails look empty.

| Word count  | Score | Status    |
| ----------- | ----- | --------- |
| 50+ words   | 5/5   | Good      |
| 20–49 words | 2/5   | Thin      |
| under 20    | 0/5   | Too short |

### 11. Branding — 5 points

Make your brand clearly identifiable, ideally in the header. The checker recognizes your organization name (including `@org-name`), a domain displayed on its own in the first text block, or a logo block with an image. Content inside snippets is checked too. A hidden link destination or an image filename alone does not count.

Visible branding earns **5/5**; otherwise, **0/5**.

See [Clear Branding](/docs/tutorials/clear-branding) for a video walkthrough, header and footer examples, and a final checklist.

## Unspam

The AI button in the toolbar has **Unspam → Improve deliverability**. It rewrites the trigger words and keeps your meaning, language, formatting, variables, and tone.

<DocsPhoto
  src="/docs/content-deliverability/unspam.png"
  alt="A printed newsletter with ACT NOW circled in red pencil"
  caption="Unspam is the red pencil. Same email, fewer spam flags."
/>

<DeliverabilityUnspam />

## How to read the score

<DeliverabilityScoreBands />

Green in the toolbar means the content is clean. It still does not promise inbox placement — DNS, sender health, and the list matter too.

## Quick checklist

<DeliverabilityDoDont />

## Related

<DocCardWrapper>

<DocCardGrid>
  <DocCard
    href="/docs/tutorials/migration-to-high-deliverability-emails"
    icon="Mail"
    title="Migration to high deliverability"
    description="Branding, why-you-receive, DOI, and workflows — including OTP."
  />
  <DocCard
    href="/docs/features/sender-health"
    icon="Settings"
    title="Sender Health"
    description="Org sending health, DNS, and SES posture."
  />
  <DocCard
    href="/docs/features/deliverability-score"
    icon="Mail"
    title="Campaign Delivery Score"
    description="The dashboard score after a send."
  />
  <DocCard
    href="/docs/features/engagement-score"
    icon="Sparkles"
    title="Engagement Score"
    description="Opens and clicks, without the guesswork."
  />
  <DocCard
    href="/docs/features/language"
    icon="Globe"
    title="Language"
    description="Translate the Unsubscribe label. Recipient pages follow Settings → Language."
  />
  <DocCard
    href="/docs/features/variables"
    icon="FileText"
    title="Variables"
    description="{{unsubscribeUrl}} and @org-adresse."
  />
  <DocCard
    href="/docs/api-reference/api-emails-send"
    icon="Code2"
    title="Send Email API"
    description="Same hygiene rules for API emails."
  />
  <DocCard
    href="/docs/domains/email-domains"
    icon="Plug"
    title="Email Domains"
    description="SPF, DKIM, and DMARC."
  />
</DocCardGrid>

</DocCardWrapper>

---

# Sender Health
Source: https://lumail.io/docs/features/sender-health.md

**Sender health** is the organization trust score already used to keep sending unlocked. It lives on **Settings → Deliverability** and **Settings → Verification**. Lumail does not introduce a fourth score formula here.

This page is **not** inbox placement. A delivered event means Amazon SES accepted the message at the receiving server.

## Where to look

- **Settings → Deliverability** — sender health, 24h/7d/30d outcome metrics, recommendations, authentication, SES infrastructure
- **Settings → Verification** — the same sender score plus domain unlock checklist
- **Dashboard campaign delivery score** — a separate campaign-window formula, documented in [Campaign Delivery Score](/docs/features/deliverability-score)
- **Editor content checks** — pre-send content hygiene, documented in [Content Deliverability Checker](/docs/features/content-deliverability)

## Metric definitions

Every metric shows its count, denominator, date window, and sample confidence. Below 50 events the sample is insufficient; below 200 it is limited. Low-volume segments stay **neutral**, not high-risk.

| Label | Definition | Denominator |
| --- | --- | --- |
| Sent | SES-accepted send attempts stored as Lumail emails | sent |
| Delivered to receiving server | SES delivery event received | sent |
| Delayed | SES delivery-delay events | sent |
| Permanent bounce | Normalized permanent bounces | sent |
| Transient bounce | Normalized transient bounces | sent |
| Reported complaint rate | Feedback-loop complaints received | delivered |
| Unique open | Unique non-bot opens | delivered |
| Unique click | Unique clicks | delivered |
| Sender health | Existing `getOrganizationTrustScore()` 30-day result | last 30 days of sending |

Tests and Amazon SES mailbox-simulator traffic are excluded from customer health.

## What “delivered” does not mean

Delivered is **not** inbox placement. The message can still land in spam. Lumail cannot reproduce Amazon SES Virtual Deliverability Manager’s optimized shared delivery, private ISP insights, or seed-panel evidence.

Recipient-provider breakdowns (Gmail, Yahoo, Microsoft, Apple, Proton, Other) are a **recipient-domain mapping**. They are not AWS VDM ISP intelligence.

## Reported complaints and opens

Reported complaint rate only includes ISPs that send feedback-loop complaints. Missing ISP coverage is not treated as a clean list.

Open rate is directional. Apple Mail Privacy Protection and image proxies inflate or hide opens. Known bots are excluded where Lumail can detect them.

## Authentication and recommendations

Each custom sending domain gets a last-checked posture for SES identity, DKIM, custom MAIL FROM MX/SPF, DMARC, and optional BIMI. BIMI is informational and never required for a healthy sender.

Recommendations are persistent OPEN/RESOLVED rows with a stable rule key, evidence, and a remediation link. WARNING/ERROR openings notify the organization once (deduped, with the existing email cooldown). A finding does not resolve just because a DNS lookup failed; resolution needs a confirming healthy scan.

Members can view Deliverability. Owners and admins can refresh infrastructure snapshots.

## Related

- [Deliverability alerts](/docs/deliverability) — how-to for every warning and error
- [Campaign Delivery Score](/docs/features/deliverability-score)
- [Content Deliverability Checker](/docs/features/content-deliverability)
- [Account Verification](/docs/features/account-verification)
- [Email Domains](/docs/domains/email-domains)

---

# Campaign Delivery Score
Source: https://lumail.io/docs/features/deliverability-score.md

The **campaign delivery score** on the organization dashboard measures campaign-window bounce, complaint, and unsubscribe pressure on a 0–100 scale. It is **not** sender health, inbox placement, or the editor’s content checks.

For organization-level sending health, authentication, and SES infrastructure, use **Settings → Deliverability**. See [Sender Health](/docs/features/sender-health).

## Score calculation

The campaign delivery score starts at **100 points** and is reduced from campaign emails in the selected dashboard date range:

- **Each 1% bounce rate = -5 points** (`bounced / total campaign emails`)
- **Each 1% complaint rate = -10 points** (`complained / total campaign emails`)
- **Each 1% unsubscribe rate = -0.5 points**

The result is clamped between 0 and 100.

This formula is independent of the 30-day **sender health** trust score used for verification.

## Understanding the metrics

### Sent

Campaign emails Lumail accepted for sending in the selected window, including messages still in flight.

### Delivered to receiving server

Emails with a delivery outcome from the receiving server. This does **not** guarantee inbox placement — the message can still land in spam.

### Bounced

Emails rejected and not delivered. Lumail tracks permanent and transient bounces. Remove permanent failures from the list.

### Complained

Recipients who used an ISP feedback loop to mark mail as spam. Coverage depends on which providers send complaints.

### Unsubscribed

Deduped unsubscribe events in the same window.

## Score interpretation

| Score range | Status | Action |
| --- | --- | --- |
| 95–100 | Excellent | Keep current list and content practices |
| 80–94 | Good | Watch bounce and complaint trends |
| 60–79 | Needs attention | Clean invalid addresses and review complaint sources |
| 0–59 | Critical | Pause and investigate before more volume |

## Related documentation

- [Sender Health](/docs/features/sender-health) — organization trust score, DNS, SES posture
- [Content Deliverability Checker](/docs/features/content-deliverability) — pre-send content checks
- [Engagement Score](/docs/features/engagement-score)
- [Email Domains](/docs/domains/email-domains)
- [Subscriber Events](/docs/features/subscriber-events)

---

# Email Engagement Score
Source: https://lumail.io/docs/features/engagement-score.md

The engagement score measures how actively your subscribers interact with your emails on a scale of 0-100. This score combines open rates and click-through rates to give you a comprehensive view of email performance.

## Score Calculation

Your engagement score is calculated from two equally weighted components:

### Open Rate Score (50 points maximum)

**Target**: 30% open rate = 50 points
**Formula**: Each 0.6% open rate = 1 point

The open rate measures the percentage of sent emails that were opened by recipients.

**Calculation**: `openRateScore = Math.min((openRate / 0.6), 50)`

**Example**:

- 30% open rate: 30 / 0.6 = **50 points** ✅
- 18% open rate: 18 / 0.6 = **30 points**
- 6% open rate: 6 / 0.6 = **10 points**

### Click Rate Score (50 points maximum)

**Target**: 10% click rate = 50 points
**Formula**: Each 0.2% click rate = 1 point

The click rate measures the percentage of sent emails that resulted in at least one click.

**Calculation**: `clickRateScore = Math.min((clickRate / 0.2), 50)`

**Example**:

- 10% click rate: 10 / 0.2 = **50 points** ✅
- 5% click rate: 5 / 0.2 = **25 points**
- 1% click rate: 1 / 0.2 = **5 points**

### Total Score

**Total Engagement Score = Open Rate Score + Click Rate Score**

**Example Scenarios**:

| Open Rate | Click Rate | Open Score | Click Score | Total Score | Rating       |
| --------- | ---------- | ---------- | ----------- | ----------- | ------------ |
| 30%       | 10%        | 50         | 50          | **100**     | Excellent 🟢 |
| 18%       | 5%         | 30         | 25          | **55**      | Good 🔵      |
| 15%       | 3%         | 25         | 15          | **40**      | Fair 🟡      |
| 6%        | 1%         | 10         | 5           | **15**      | Poor 🔴      |

## How We Track Opens

### Open Tracking Technology

Email opens are tracked using a **1×1 pixel tracking image** embedded in the HTML email. When a recipient opens the email and their email client loads images, this pixel sends a signal back to our servers.

**Technical details**:

- A unique tracking pixel URL is generated for each email
- The pixel is invisible to the recipient (1×1 transparent image)
- When loaded, it triggers a server request that records the open event
- We capture the timestamp, IP address, and user agent

### Unique Opens vs. Total Opens

**Lumail tracks unique opens only**, meaning:

- **First open**: Counted in metrics
- **Subsequent opens**: Marked as duplicate and not counted

This is stored in the `emailOpen` table with an `isDuplicate` flag:

```typescript
const uniqueOpensCount = await prisma.emailOpen.count({
  where: {
    email: { organizationId, createdAt: { gte, lte } },
    isDuplicate: false, // Only count unique opens
  },
});
```

**Why unique opens?**: This prevents inflating your metrics when someone opens an email multiple times.

### Open Tracking Limitations

**Important**: Open tracking is not 100% accurate due to:

1. **Image Blocking**: Some email clients (like Outlook) block images by default
2. **Privacy Features**: Apple Mail Privacy Protection pre-loads images on their servers, not user devices
3. **Plain Text Emails**: Opens cannot be tracked in plain text emails
4. **Preview Panes**: Some clients load images in preview panes, triggering false opens

**Typical accuracy**: ~70-80% of actual opens are tracked

### Apple Mail Privacy Protection Impact

Since iOS 15, Apple Mail pre-loads tracking pixels even if the user never opens the email. This can:

- Inflate open rates for Apple Mail users
- Make timing data unreliable
- Affect A/B testing based on send time

**Our approach**: We still track these opens but recommend focusing on click rates for Apple Mail-heavy lists. Lumail also classifies each open as human or bot (Apple Mail prefetch, Gmail proxy, security scanners, and more) so you can see how much of your open rate is automated — see [Bot vs Human Opens](/docs/features/bot-detection).

## How We Track Clicks

### Click Tracking Technology

All links in your emails are automatically rewritten to route through our tracking servers.

**Process**:

1. Original link: `https://yoursite.com/blog/post`
2. Rewritten link: `https://lumail.io/c/[unique-id]`
3. User clicks → our server records the click → redirects to original URL

**What we capture**:

- Timestamp of the click
- IP address (for geographic data)
- User agent (device/browser information)
- The specific link clicked
- Which email the click came from

### Unique Clicks vs. Total Clicks

Like opens, **Lumail tracks unique clicks only**:

- **First click on any link**: Counted in metrics
- **Subsequent clicks**: Marked as duplicate and not counted

This is stored in the `emailClick` table:

```typescript
const uniqueClicksCount = await prisma.emailClick.count({
  where: {
    email: { organizationId, createdAt: { gte, lte } },
    isDuplicate: false, // Only count unique clicks
  },
});
```

**Note**: A unique click counts the first click per email, regardless of which link was clicked. Multiple clicks on different links in the same email are still marked as duplicates.

### Click Rate Calculation

Click rate is calculated as **clicks / sent**. Click-to-open (clicks / opens) is a separate metric on campaign reports.

```
clickRate = (uniqueClicks / totalSent) × 100
clickToOpenRate = (uniqueClicks / uniqueOpens) × 100
```

**Example**:

- Sent: 10,000 emails
- Opened: 2,500 (25% open rate)
- Clicked: 200
- **Click rate**: 200 / 10,000 = **2%**
- **Click-to-open**: 200 / 2,500 = **8%**

### Unsubscribe Link Tracking

Unsubscribe links are also click-tracked, but they:

- Don't count toward your engagement score
- Are automatically excluded from click rate calculations
- Are still logged for compliance and analytics

## Understanding the Metrics

### Sent

The total number of emails sent to recipients. This is the baseline for calculating open rate.

**Formula**: `openRate = (uniqueOpens / totalSent) × 100`

### Opened

The number of unique recipients who opened your email at least once.

**Best practice**: If your open rate is low (&lt;15%), consider:

- Testing different subject lines
- Sending at different times of day
- Cleaning your list of inactive subscribers
- Improving sender name recognition

### Clicked

The number of unique recipients who clicked at least one link in your email.

**Best practice**: If your click rate is low (&lt;2%), consider:

- Making CTAs more prominent
- Reducing the number of links (fewer options = more clicks)
- Improving email copy and relevance
- Using more compelling action-oriented language

## Score Interpretation

| Score Range | Status         | Action Required                                           |
| ----------- | -------------- | --------------------------------------------------------- |
| 80-100      | Excellent (🟢) | Your emails are highly engaging. Keep doing what works!   |
| 60-79       | Good (🔵)      | Solid performance. Test improvements to reach excellent.  |
| 40-59       | Fair (🟡)      | Room for improvement. Focus on subject lines and content. |
| 0-39        | Poor (🔴)      | Immediate attention needed. Review content strategy.      |

## Improving Your Score

### Boost Open Rates

1. **Craft compelling subject lines**
   - Use curiosity, urgency, or personalization
   - Keep it under 50 characters
   - A/B test different approaches

2. **Optimize sender name**
   - Use a recognizable person or brand name
   - Avoid "noreply@" addresses
   - Stay consistent

3. **Send at optimal times**
   - Test different days and times
   - Consider your audience's timezone
   - Avoid weekends for B2B

4. **Clean your list regularly**
   - Remove hard bounces immediately
   - Consider removing subscribers with no opens in 6+ months
   - Use re-engagement campaigns before removing

5. **Segment your audience**
   - Send more relevant content to smaller groups
   - Personalize based on interests or behavior
   - Avoid one-size-fits-all campaigns

### Boost Click Rates

1. **Clear call-to-action**
   - One primary CTA per email
   - Make buttons stand out
   - Use action-oriented language ("Get Started", not "Click Here")

2. **Relevant content**
   - Match email content to subscriber interests
   - Deliver on subject line promises
   - Provide immediate value

3. **Optimize email design**
   - Use short paragraphs
   - Include plenty of white space
   - Make links and buttons easy to tap on mobile

4. **Create urgency**
   - Limited-time offers
   - Exclusive content
   - Early-bird access

5. **Test everything**
   - A/B test CTA placement
   - Try different button colors and sizes
   - Test text links vs. buttons

## Industry Benchmarks

For context, here are typical industry benchmarks:

| Metric                | Excellent | Good   | Needs Work |
| --------------------- | --------- | ------ | ---------- |
| Open Rate             | >25%      | 15-25% | &lt;15%    |
| Click Rate (of sent)  | >4%       | 2-4%   | &lt;2%     |
| Engagement Score      | >80       | 60-80  | &lt;60     |

**Note**: Benchmarks vary by industry, audience size, and email type (newsletter vs. promotional). Focus on improving your own metrics over time rather than comparing to others.

## Technical Details

The engagement score is calculated using this formula:

```typescript
function calculateEngagementScore(openRate: number, clickRate: number) {
  const openRateScore = Math.min(openRate / 0.6, 50);
  const clickRateScore = Math.min(clickRate / 0.2, 50);

  return Math.round(openRateScore + clickRateScore);
}
```

Where:

- `openRate` = (unique opens / total sent) × 100
- `clickRate` = (unique clicks / total sent) × 100

This balanced approach ensures both metrics contribute equally to your overall engagement health.

## Related Documentation

- [Bot vs Human Opens](/docs/features/bot-detection) - How opens and clicks are classified as human or automated
- [Deliverability Score](/docs/features/deliverability-score) - Monitor sender reputation
- [Content Deliverability Checker](/docs/features/content-deliverability) - Optimize email content before sending
- [Subscriber Events](/docs/features/subscriber-events) - View open and click events in timeline
- [Variables](/docs/features/variables) - Personalize emails to boost engagement

---

# Bot vs Human Opens
Source: https://lumail.io/docs/features/bot-detection.md

A growing share of email "opens" are not people. Apple Mail Privacy Protection prefetches images, Gmail proxies them, and corporate security gateways scan every link before the recipient ever sees the message. Lumail classifies each open and click so you can tell real engagement from automated noise.

![One delivered email can record several opens: a security gateway, Apple Mail privacy, and the Gmail image proxy each trigger the tracking pixel before the subscriber reads the email](/docs/bot-detection/tracking-journey.svg)

## What gets classified

Every tracked open and click is evaluated at the moment it is recorded:

- **Human**: the event was evaluated and showed no automation signal.
- **Bot**: the event matched one of the detection rules below.
- **Not evaluated** (clicks only): the click was recorded before click classification rolled out on September 6, 2026. Historical clicks are never re-evaluated.

Classification is **informational**: it never changes your unique open and click counts, your open rate, click rate, or engagement score. A bot open still counts as an open. Instead, Lumail shows you alongside those numbers how much of them you can trust.

![Clicks have three possible labels: clicks recorded before the September 6, 2026 rollout stay "not evaluated"; newer clicks are either human (no automation signal) or bot (automation detected)](/docs/bot-detection/click-classification.svg)

## Detection reasons

When an open or click is flagged as automated, Lumail records why:

| Reason               | What it means                                                                                                        |
| -------------------- | -------------------------------------------------------------------------------------------------------------------- |
| **Apple Mail**       | Apple Mail Privacy Protection prefetched the tracking pixel on Apple's servers, whether or not the email was read     |
| **Gmail Proxy**      | Gmail's image proxy fetched the pixel on Google's infrastructure                                                      |
| **Security Scanner** | An email gateway (Yahoo, Outlook, Barracuda, and similar) scanned the message before delivery to the inbox            |
| **Fast Open**        | The email was opened within 2 minutes of authenticated delivery, faster than a human realistically reads a new email  |
| **Bot**              | The same email was opened from several different browser identities, a pattern typical of impersonation bots          |
| **Crawler**          | The user agent explicitly identified itself as automation or a crawler                                                |

Detection is best-effort. Some bots hide behind residential IPs and realistic user agents, and some fast human readers can be flagged. Treat the split as a strong signal, not an exact count.

Here is how the split reads on a concrete campaign:

![Example with 1,000 emails sent: 450 opened for a 45% open rate, of which 280 are human opens (28% human open rate) and 170 are bot opens (38% of all opens), flagged mostly as Apple Mail privacy, security scanners, fast opens, and the Gmail image proxy](/docs/bot-detection/open-rate-composition.svg)

## How "first open" is classified

Unique opens count the **first interaction per email**. The human/bot label describes that earliest recorded event:

![Two timelines: in scenario A a security scanner opens at 09:00 so the first open is a bot open and the subscriber's 10:12 open is a re-open; in scenario B the subscriber opens at 08:47 so the first open is human and the 09:05 Apple Mail prefetch is a re-open](/docs/bot-detection/first-open-classification.svg)

- If a security scanner opened the email first and the recipient opened it an hour later, the first open is a **bot open** and the recipient's open is a re-open.
- If the recipient opened first, the first open is **human** even if bots fetch the pixel later.

Subscriber activity follows the first **human** open instead. The recipient's later open still records an "Email opened" event, increments their open count, and can trigger workflow and segment conditions, even when a bot opened the email first.

This is why a list with heavy Apple Mail usage can show a high open rate but a much lower human open rate.

## Where you see the split

- **Campaign report**: the Engagement card shows how many first opens were flagged as automated, with a badge per detection reason.
- **Email detail**: each email's open breakdown separates first human opens, first bot opens, and re-opens.
- **Campaign Activity chart**: opens and clicks over time are split into human, bot, and (for clicks) not evaluated.
- **Dashboard**: the Email Engagement card footer summarizes opens flagged as automated and the human / bot / not evaluated click split for the selected period.

## What to do with it

1. **Judge campaigns on human opens and clicks.** If 40% of your opens are Apple Mail prefetches, a "great" open rate may hide a flat one.
2. **Prefer clicks over opens for decisions.** A human click is the strongest engagement signal Lumail tracks; bot clicks from link scanners are filtered out of the human count.
3. **Don't panic about bot opens.** They are a property of your audience's email clients, not a deliverability problem. A B2B list behind corporate gateways will always show security-scanner opens.
4. **Watch the trend, not the absolute number.** A sudden spike in bot opens with no matching sends can indicate list-quality issues or a scanner replaying old links.

## Related Documentation

- [Email Engagement Score](/docs/features/engagement-score) - How open and click rates build your score
- [Deliverability Score](/docs/features/deliverability-score) - Monitor sender reputation
- [Subscriber Events](/docs/features/subscriber-events) - View open and click events in timeline

---

# Subscriber Events
Source: https://lumail.io/docs/features/subscriber-events.md

Lumail automatically tracks various subscriber events to help you understand subscriber behavior and engagement. These events appear in the subscriber timeline and can be used for segmentation and automation triggers.

Most events are created automatically by the system as subscribers interact with your emails or undergo status changes. However, some events like payments and refunds need to be manually tracked through the [Events API](/docs/api-reference/api-events-post).

## Revenue Events (Manual Tracking Required)

These events need to be manually tracked via the Events API. See [Revenue Tracking](/docs/features/revenue-tracking) for implementation guide.

### SUBSCRIBER_PAYMENT

Records when a subscriber makes a payment or purchase.

**Required Data Fields:**

- `amount`: number - The payment amount
- `product`: string - Name of the product or service purchased

### SUBSCRIBER_REFUND

Records when a subscriber receives a refund.

**Required Data Fields:**

- `amount`: number - The refund amount
- `product`: string - Name of the product or service refunded

## Email Engagement Events (Automatic)

These events are tracked automatically by Lumail:

### EMAIL_SENT

Recorded when an email is sent to a subscriber.

**Tracked Data:**

- `emailId`: string - Unique identifier for the email
- `campaignId`: string - ID of the associated campaign
- `subject`: string - Subject line of the email
- `workflow`: object (optional) - Contains workflow ID and name if sent via automation

### EMAIL_RECEIVED

Recorded when an email is successfully delivered to a subscriber's inbox.

**Tracked Data:**

- `emailId`: string - Unique identifier for the email
- `campaignId`: string - ID of the associated campaign
- `subject`: string - Subject line of the email
- `receivedAt`: string - Timestamp when the email was delivered

### EMAIL_OPENED

Recorded when a subscriber opens an email.

**Tracked Data:**

- `emailId`: string - Unique identifier for the email
- `campaignId`: string - ID of the associated campaign
- `subject`: string - Subject line of the email

### EMAIL_CLICKED

Recorded when a subscriber clicks a link in an email.

**Tracked Data:**

- `emailId`: string - Unique identifier for the email
- `campaignId`: string - ID of the associated campaign
- `subject`: string - Subject line of the email
- `link`: string - The URL that was clicked
- `userAgent`: string (optional) - Browser and device information

### EMAIL_DELIVERY_DELAYED

Recorded when email delivery is temporarily delayed.

**Tracked Data:**

- `emailId`: string - Unique identifier for the email
- `campaignId`: string (optional) - ID of the associated campaign
- `subject`: string - Subject line of the email
- `workflow`: object (optional) - Contains workflow ID and name if sent via automation

### EMAIL_BOUNCED

Recorded when an email cannot be delivered to a subscriber.

**Tracked Data:**

- `emailId`: string - Unique identifier for the email
- `campaignId`: string - ID of the associated campaign
- `subject`: string - Subject line of the email
- `reason`: string - Reason for the bounce
- `bounceType`: string - Type of bounce (hard or soft)
- `userEmail`: string (optional) - Email address that bounced

### EMAIL_COMPLAINED

Recorded when a subscriber marks an email as spam.

**Tracked Data:**

- `emailId`: string - Unique identifier for the email
- `campaignId`: string - ID of the associated campaign
- `subject`: string - Subject line of the email

## Subscription Events (Automatic)

These events are related to subscriber status changes:

### SUBSCRIBED

Recorded when a new subscriber joins your list.

**Tracked Data:**

- `tags`: array (optional) - Initial tags applied to the subscriber

### UNSUBSCRIBED

Recorded when a subscriber opts out of your list.

**Tracked Data:**

- `reason`: string (optional) - Reason provided for unsubscribing
- `campaignId`: string (optional) - ID of the campaign that triggered the unsubscribe
- `campaignName`: string (optional) - Name of the campaign that triggered the unsubscribe
- `status`: string (optional) - Updated subscriber status

## Tag Events (Automatic)

These events track changes to subscriber tags. Manage tags programmatically with the [Tags API](/docs/api-reference/api-tags-get) and [Add Tags API](/docs/api-reference/api-subscriber-tags-post).

### TAG_ADDED

Recorded when a tag is applied to a subscriber.

**Tracked Data:**

- `tagId`: string - Unique identifier for the tag
- `tagName`: string - Name of the tag
- `reason`: string (optional) - Reason the tag was added

### TAG_REMOVED

Recorded when a tag is removed from a subscriber.

**Tracked Data:**

- `tagId`: string - Unique identifier for the tag
- `tagName`: string - Name of the tag
- `reason`: string (optional) - Reason the tag was removed

## Workflow Events (Automatic)

These events track subscriber journey through automated workflows. Learn more about [Workflows](/docs/workflows).

### WORKFLOW_STARTED

Recorded when a subscriber enters an automation workflow.

**Tracked Data:**

- `workflowId`: string - Unique identifier for the workflow
- `workflowName`: string - Name of the workflow

### WORKFLOW_COMPLETED

Recorded when a subscriber completes all steps in a workflow.

**Tracked Data:**

- `workflowId`: string - Unique identifier for the workflow
- `workflowName`: string - Name of the workflow

### WORKFLOW_CANCELED

Recorded when a subscriber is removed from a workflow before completion.

**Tracked Data:**

- `workflowId`: string - Unique identifier for the workflow
- `workflowName`: string - Name of the workflow
- `reason`: string (optional) - Reason for cancellation

### WEBHOOK_EXECUTED

Recorded when a webhook step within a workflow is triggered for a subscriber.

**Tracked Data:**

- `workflowId`: string - Unique identifier for the workflow
- `workflowName`: string - Name of the workflow
- `stepId`: string - ID of the specific workflow step
- `webhookUrl`: string - The URL that was called
- `status`: number - HTTP status code returned
- `success`: boolean - Whether the webhook was successfully executed
- `errorMessage`: string (optional) - Error message if the webhook failed

## Other Events (Automatic)

### FIELD_UPDATED

Recorded when a custom field value is updated for a subscriber.

**Tracked Data:**

- `fieldId`: string - Unique identifier for the field
- `fieldName`: string - Name of the field
- `oldValue`: string (optional) - Previous value of the field
- `newValue`: string - New value of the field

## Using Events in Lumail

Subscriber events have several useful applications:

1. **Subscriber Timeline** - View a chronological history of all subscriber interactions
2. **Segmentation** - Create targeted segments based on subscriber behavior
3. **Automation Triggers** - Start workflows when specific events occur
4. **Analytics** - Measure engagement and conversion rates
5. **Revenue Tracking** - Track purchase behavior and calculate subscriber lifetime value

For more information on how to manually track events, see the [Events API documentation](/docs/api-reference/api-events-post).

To retrieve a subscriber's event history programmatically, see the [Get Subscriber Events API](/docs/api-reference/api-subscriber-events-get).

---

# Revenue Tracking
Source: https://lumail.io/docs/features/revenue-tracking.md

Lumail allows you to track purchase activity and revenue associated with your subscribers. This enables you to:

- Calculate subscriber lifetime value (LTV)
- Segment subscribers by purchase behavior
- Measure ROI of your email campaigns
- Create targeting based on purchase history
- Build automation workflows triggered by purchases

## How Revenue Tracking Works

Lumail tracks revenue by recording payment events against individual subscribers. Each payment event increases the subscriber's `revenue` counter, providing a running total of their lifetime value.

These payment events must be manually tracked through the [Events API](/docs/api-reference/api-events-post), allowing you to integrate with your existing payment system or e-commerce platform.

## Setting Up Revenue Tracking

To track revenue for your subscribers, you'll need to send payment events to Lumail when purchases occur. Here's how to set it up:

### 1. Implement Event Tracking in Your Payment System

When a customer makes a purchase, you'll need to:

1. Identify the subscriber by email address
2. Record the purchase amount and details
3. Send a `SUBSCRIBER_PAYMENT` event to Lumail

### 2. Send Payment Events to the API

Use the [Events API](/docs/api-reference/api-events-post) to send payment information:

```javascript
const response = await fetch("https://lumail.io/api/v1/events", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    eventType: "SUBSCRIBER_PAYMENT",
    subscriber: "customer@example.com",
    data: {
      amount: 49.99,
      product: "Premium Subscription",
    },
  }),
});
```

### Required Payment Data Fields

| Field     | Type   | Description                              |
| --------- | ------ | ---------------------------------------- |
| `amount`  | number | The payment amount (required)            |
| `product` | string | Name of the product purchased (required) |

### 3. Track Refunds (Optional)

If you process refunds, you should also track these using the `SUBSCRIBER_REFUND` event:

```javascript
const response = await fetch("https://lumail.io/api/v1/events", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    eventType: "SUBSCRIBER_REFUND",
    subscriber: "customer@example.com",
    data: {
      amount: 49.99,
      product: "Premium Subscription",
    },
  }),
});
```

### 4. View Revenue Data

Once you've implemented revenue tracking, you can:

- See payment events in the subscriber timeline
- View total revenue per subscriber in the subscribers list
- Use revenue data for segmentation
- Create reports based on revenue metrics

## Integration Examples

### E-commerce Integration

If you run an e-commerce store, you can integrate revenue tracking on your order confirmation page:

```javascript
// Example code to run after successful checkout
async function trackOrderInLumail(order) {
  await fetch("https://lumail.io/api/v1/events", {
    method: "POST",
    headers: {
      Authorization: "Bearer YOUR_API_TOKEN",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      eventType: "SUBSCRIBER_PAYMENT",
      subscriber: order.customerEmail,
      data: {
        amount: order.total,
        product: order.items.map((item) => item.name).join(", "),
      },
    }),
  });
}
```

### Subscription System Integration

For subscription businesses, track recurring payments:

```javascript
// Example webhook handler for subscription payment events
app.post("/webhook/payment-succeeded", async (req, res) => {
  const event = req.body;

  // Track the payment in Lumail
  try {
    await fetch("https://lumail.io/api/v1/events", {
      method: "POST",
      headers: {
        Authorization: "Bearer YOUR_API_TOKEN",
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        eventType: "SUBSCRIBER_PAYMENT",
        subscriber: event.customer.email,
        data: {
          amount: event.amount / 100, // Convert from cents
          product: event.product.name,
        },
      }),
    });

    res.status(200).send("Event processed");
  } catch (error) {
    console.error("Error tracking payment:", error);
    res.status(500).send("Error processing event");
  }
});
```

## Best Practices

1. **Be Consistent**: Use the same format for all payment tracking
2. **Track Immediately**: Send payment events as soon as the transaction is confirmed
3. **Include Product Info**: Add descriptive product names to help with analysis
4. **Track Refunds**: Always record refunds to keep revenue data accurate
5. **Test First**: Verify your integration with test purchases before full deployment

## Related Documentation

- [Subscriber Events Reference](/docs/features/subscriber-events) - Complete guide to all event types
- [Track Events API](/docs/api-reference/api-events-post) - Technical details for sending events
- [Get Subscriber Events API](/docs/api-reference/api-subscriber-events-get) - Retrieve payment event history
- [Subscribers API](/docs/api-reference/api-subscribers-get) - View and manage subscriber data
- [ClickFunnels Integration](/docs/integrations/clickfunnel-integration) - Track order events automatically

---

# Email Sending Queue
Source: https://lumail.io/docs/features/email-sending-queue.md

Every email leaving Lumail goes through a shared sending system. Understanding it explains
why a campaign takes the time it takes, and why a workflow email sometimes arrives a few
minutes after the action that triggered it.

## Two priority lanes

| Lane        | What goes in it                                                                                       | Priority                         |
| ----------- | ----------------------------------------------------------------------------------------------------- | -------------------------------- |
| **Instant** | Transactional emails (login codes, notifications, API/SMTP single sends) and campaign **test emails** | Always sent first                |
| **Bulk**    | **Campaigns and workflow emails**                                                                     | Sent with the remaining capacity |

Whenever an instant email is waiting, bulk sending pauses until it goes out. Instant emails
are rare and tiny in volume, so this pause is measured in milliseconds — but it guarantees
that a password reset never sits behind a 50 000-recipient campaign.

## Workflow emails are queued like campaigns

Each email step in a workflow is backed by a campaign behind the scenes. That means:

- Workflow emails use the **same bulk lane** as regular campaigns.
- They join the **same sending queue for your organization**.
- They are **not prioritized** over campaigns, and campaigns are not prioritized over them.

Inside one organization, the queue is **first in, first out**: whatever was queued first is
sent first. If a large campaign starts sending at 10:00 and a subscriber triggers a workflow
email at 10:01, that workflow email is sent once the campaign ahead of it has finished
draining.

If you need a workflow email to go out immediately regardless of campaign volume, avoid
scheduling large campaigns during the windows where that workflow is most active.

## Fair share between organizations

Your queue is never blocked by another customer's campaign. Sending capacity is split
across all organizations that have emails waiting:

- Half of the available capacity is **reserved for paid plans**.
- The rest is open to every organization, free plans included.
- Within each pool, capacity is distributed by plan weight: **Business** organizations get
  more slots than **Premium**, which get more than **Free**.
- Organizations that were short-changed in one round are prioritized in the next one, so
  the split evens out over time.

Practically: if you are the only organization sending, you get the full throughput. If ten
organizations are sending at once, capacity is shared — and your plan determines your share.

## What this means for timing

- **Small campaigns and workflow emails** typically leave within seconds.
- **Large campaigns** are delivered progressively, at a steady, deliverability-friendly
  rate rather than in one burst. This is intentional: sending everything at once damages
  sender reputation.
- **Progress is visible on the campaign page** while sending, so you can follow how many
  recipients have been processed.
- Emails are never dropped when the queue is busy — they wait, and each recipient's state
  is tracked individually so a retry never produces a duplicate.

## When sending stops

Sending pauses for your organization in two cases:

- **Monthly quota reached** — campaigns are parked and workflow runs are paused. Both resume
  automatically at the start of the next period, or as soon as capacity frees up.
- **Plan suspension** — same behavior: nothing is lost, everything resumes once the account
  is active again.

## Related Documentation

- [Email Step](/docs/workflows/workflow-email-step) - Send emails from a workflow
- [Deliverability Score](/docs/features/deliverability-score) - Monitor your sender reputation
- [Subscriber Events](/docs/features/subscriber-events) - Track delivery, opens and clicks

---

# Features
Source: https://lumail.io/docs/features.md

Lumail.io offers powerful features to help you build better email campaigns and track your marketing performance.

## Key Features

### [Affiliate Program](/docs/features/affiliate-program)

Create a personal referral link, turn Lumail email badges into affiliate links, and track €50 Premium rewards after two months plus immediate €200 Business rewards.

### [Enable Double Opt-In](/docs/tutorials/enable-double-opt-in)

Require new subscribers to confirm their email before campaigns. Native confirmation from Settings → Configuration.

### [Language](/docs/features/language)

Switch unsubscribe, confirmation, and survey pages to French, Spanish, or German. Email footer copy and the Unsubscribe link are edited in the campaign or a snippet — they are not auto-translated.

### [Variables](/docs/features/variables)

Personalize your emails with dynamic variables like `{{name}}`. Add default values with `{{name | 'Hey'}}` for subscribers without data.

### [Tag Action Links](/docs/features/tag-action-links)

Let subscribers add or remove tags with one click in non-transactional emails. Use them for interest capture, topic opt-outs, preference centers, and workflow triggers.

### [Surveys](/docs/survey)

Ask a one-question poll inside the email. Recipients click an answer — buttons, yes/no, emoji, or NPS — and the result lands on the campaign.

### [Content Deliverability Checker](/docs/features/content-deliverability)

Real-time email analysis that checks 9 deliverability factors as you write - spam words, links, subject length, and more. Includes AI-powered "Unspam" to automatically fix issues.

### [Deliverability Score](/docs/features/deliverability-score)

Monitor your sender reputation with post-send metrics. Track bounce rates, spam complaints, and overall deliverability health.

### [Engagement Score](/docs/features/engagement-score)

Measure campaign performance with open and click rate analysis. Understand how your audience interacts with your emails.

### [Subscriber Events](/docs/features/subscriber-events)

Track user activity and behavior to create targeted campaigns. Monitor opens, clicks, and custom events.

### [Revenue Tracking](/docs/features/revenue-tracking)

Connect purchases to email campaigns and measure your email marketing ROI with detailed revenue analytics.

### [Email Sending Queue](/docs/features/email-sending-queue)

Understand how campaigns and workflow emails share your organization's sending queue, how priority lanes work, and how capacity is split between plans.

### [Workflows](/docs/workflows)

Automate your email sequences with powerful workflow builders. Create drip campaigns and behavioral triggers.

## Getting Started

Explore each feature in detail to learn how to maximize your email marketing results with Lumail.io.

---

# How to strengthen DMARC
Source: https://lumail.io/docs/deliverability/how-to-strengthen-dmarc.md

You got **DMARC policy is monitoring only**. The record exists and is valid. The problem is the policy: `p=none`.

`p=none` tells receiving servers: *check DMARC, send me reports, and still deliver spoofed mail*. Nothing is quarantined or rejected. Gmail, Yahoo, and other inboxes treat that as “not enforced.”

Lumail does not rewrite DNS. You change the TXT record at your DNS host.

## What to change

Find the `_dmarc` TXT record and replace `p=none` with `p=quarantine` or `p=reject`. Leave `v=DMARC1` and any `rua=` / `ruf=` report addresses as they are.

| | Record |
| --- | --- |
| Type | `TXT` |
| Host | `_dmarc` on the **domain that actually publishes the policy** (see below) |
| Before | `v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com` |
| After | `v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com` |

Then later, when reports look clean: `p=reject`.

## If you send from a subdomain

This is the usual source of confusion.

For `hey.melvynx.com`, Lumail first looks up `_dmarc.hey.melvynx.com`. If that name has no DMARC record, it uses the **organizational domain**: `_dmarc.melvynx.com`. The alert still names the sending domain (`hey.melvynx.com`), but the record you must edit is on `melvynx.com`.

The domain page shows this as **Inherited from melvynx.com** and lists the host `_dmarc.melvynx.com`.

| Sending domain | Record to edit | Do not edit |
| --- | --- | --- |
| `hey.melvynx.com` (inherited) | `_dmarc.melvynx.com` | `_dmarc.hey.melvynx.com` unless you want a *different* policy for that subdomain |
| `melvynx.com` | `_dmarc.melvynx.com` | — |

If the root record has `sp=none`, that subdomain policy also stays monitoring-only. Change `sp=` to `quarantine` or `reject` as well, or publish a dedicated `_dmarc` on the subdomain.

## Step by step

1. Open the sending domain in Lumail (**Domains → the domain in the alert**).
2. Read the DMARC row. If it says **Inherited from** the root domain, the host to edit is `_dmarc` on that root (for example `_dmarc.melvynx.com`).
3. Copy the current value. You only change `p=none` (and `sp=none` if present).
4. At your DNS host (Cloudflare, Porkbun, Route 53, Namecheap, …), edit that TXT record.
5. Wait for DNS (often minutes, sometimes up to 48 hours).
6. Back on the domain page, click **Check that they are live** / verify. Lumail’s next deliverability scan will clear the alert when it sees `p=quarantine` or `p=reject`.

## Which policy to pick

| Policy | What receivers do with failing mail |
| --- | --- |
| `p=none` | Deliver it. Monitoring only. This is the alert. |
| `p=quarantine` | Treat it as suspicious (usually junk). Safe first step. |
| `p=reject` | Refuse it. Use after reports show almost all legitimate mail passes. |

Do not jump to `p=reject` if you still send from other platforms (Google Workspace, a transactional ESP, a helpdesk) that are not aligned in SPF/DKIM. Those sends will start failing at the inbox.

## Review reports first if you have `rua=`

If the record already has `rua=mailto:…`, read a few days of aggregate reports before `p=reject`. You want your Lumail sending domain (and any other real senders) passing SPF or DKIM and aligned with the From domain.

No `rua=` is fine. You can still move to `p=quarantine` if Lumail, Google Workspace / Microsoft 365, and any other ESP you use already have working SPF + DKIM.

## Common mistakes

- Editing `_dmarc` on the **subdomain** while the live policy is on the **root**. The inherited `p=none` stays in effect until the root record changes (or you publish a stronger subdomain record).
- Adding a second `_dmarc` TXT next to the first. There must be one DMARC record at that name.
- Breaking the record: it must start with `v=DMARC1` and `p=` must be exactly `none`, `quarantine`, or `reject`.
- Expecting Lumail to flip the policy. We only read DNS.

## After you change it

The alert is `domain:dmarc:monitoring-only`. It resolves when a confirming scan sees `p=quarantine` or `p=reject` on the record Lumail actually uses (exact domain or inherited root). A failed lookup does not resolve it.

## Related

- [How to add DMARC](/docs/deliverability/how-to-add-dmarc)
- [Email Domains](/docs/domains/email-domains)
- [Sender Health](/docs/features/sender-health)

---

# How to add DMARC
Source: https://lumail.io/docs/deliverability/how-to-add-dmarc.md

You got **is missing DMARC** or **DMARC record is malformed**. Receiving servers have no usable policy for the From domain.

Lumail does not create this record for you.

## What to add

| Type | Host | Value |
| --- | --- | --- |
| TXT | `_dmarc` | `v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com` |

Use your own mailbox for `rua=` if you want aggregate reports. `rua=` is optional.

`p=none` is a valid starting policy. Lumail will then open **DMARC policy is monitoring only** until you move to `quarantine` or `reject`. That is expected — see [How to strengthen DMARC](/docs/deliverability/how-to-strengthen-dmarc).

## Malformed record

The TXT must:

- Start with `v=DMARC1`
- Include `p=none`, `p=quarantine`, or `p=reject`

Typical breaks: a CNAME on `_dmarc`, two TXT records, `p=` missing, or a typo like `v=DMARC`.

## Subdomains

If you send from `news.example.com` and `_dmarc.news.example.com` is empty, Lumail uses `_dmarc.example.com`. Publishing DMARC only on the root is enough. Publish a subdomain record only when that subdomain needs a different policy.

## After you add it

On **Domains → the domain**, click verify / **Check that they are live**. DNS can take up to 48 hours.

Then send a test email to the [mail tester](/tools/mail-tester) to see the DMARC result a receiving server gets.

## Related

- [How to strengthen DMARC](/docs/deliverability/how-to-strengthen-dmarc)
- [Email Domains](/docs/domains/email-domains)

---

# How to fix DKIM
Source: https://lumail.io/docs/deliverability/how-to-fix-dkim.md

You got **DKIM is missing, failed, or weak**. Receiving servers cannot authenticate the message with DKIM, or the key is too weak.

Without DKIM (and alignment), DMARC cannot pass on the DKIM path.

## What to add

Open **Domains → the failing domain**. Copy the **DKIM** CNAME rows exactly. Lumail issues three CNAMEs:

| Type | Host | Value |
| --- | --- | --- |
| CNAME | `TOKEN._domainkey` | `TOKEN.dkim.amazonses.com` (region-specific host from the page) |

Do not invent tokens. Do not turn them into TXT records. Do not flatten the CNAME to an A/AAAA.

If the domain page shows **Legacy Easy DKIM**, keep those records; do not mix them with a second DKIM setup on the same name.

## Checklist

1. Each host is under `_domainkey` on **this** sending domain.
2. The value matches the domain page (copy, don’t retype).
3. Proxy/orange-cloud is **off** on Cloudflare — DNS only.
4. Wait for propagation, then verify on the domain page.

## After it passes

DKIM must stay published. Deleting a CNAME later reopens this alert and will fail DMARC alignment for Lumail mail.

To confirm on a real message, send a test email to the [mail tester](/tools/mail-tester): the DKIM line should pass and align with your From domain.

## Related

- [How to verify a sending domain](/docs/deliverability/how-to-verify-sending-domain)
- [Email Domains](/docs/domains/email-domains)

---

# How to verify a sending domain
Source: https://lumail.io/docs/deliverability/how-to-verify-sending-domain.md

You got **is not verified for sending**. The email system has not verified this identity. Mail from this domain can be delayed or rejected until it is.

## What to add

Open **Domains → the domain**. Add every row that is not verified:

1. Domain verification (TXT or CNAME — copy the page, don’t guess).
2. SPF TXT on the apex / sending name, including `include:amazonses.com`.
3. DKIM CNAMEs — [How to fix DKIM](/docs/deliverability/how-to-fix-dkim).
4. MAIL FROM MX + SPF on `ses.your-domain.com` — [How to fix MAIL FROM](/docs/deliverability/how-to-fix-mail-from).
5. DMARC on `_dmarc` — [How to add DMARC](/docs/deliverability/how-to-add-dmarc).

Cloudflare: DNS-only (grey cloud) on these records. A proxied CNAME will fail verification.

## Recheck

On the domain page, click **Check that they are live**. Propagation can take up to 48 hours. Lumail does not rewrite DNS.

Once the identity verifies, sending from that domain unlocks for that From address. Send a test email to the free [mail tester](/tools/mail-tester) to see SPF, DKIM and DMARC pass on a real message. See [Account Verification](/docs/features/account-verification) if the org is still in limited mode.

## Related

- [Email Domains](/docs/domains/email-domains)
- [How to fix DKIM](/docs/deliverability/how-to-fix-dkim)
- [How to fix MAIL FROM](/docs/deliverability/how-to-fix-mail-from)

---

# How to fix MAIL FROM
Source: https://lumail.io/docs/deliverability/how-to-fix-mail-from.md

You got **custom MAIL FROM MX/SPF is incomplete**. Lumail sends with a custom MAIL FROM of `ses.your-domain.com`. That hostname needs its own MX and SPF. Without them, bounce handling and SPF alignment break.

## What to add

Copy the MAIL FROM rows from **Domains → the domain**. They look like this:

| Type | Host | Value |
| --- | --- | --- |
| MX | `ses.your-domain.com` | `feedback-smtp.REGION.amazonses.com` (priority `10`) |
| TXT | `ses.your-domain.com` | `v=spf1 include:amazonses.com ~all` |

The MX target is region-specific. Use the value on the domain page, not a guess.

## Checklist

- Host is `ses` **relative to the sending domain** (so `ses.hey.melvynx.com` if you send from `hey.melvynx.com`).
- MX priority is `10`.
- SPF is a TXT on the same `ses` hostname, not only on the apex.
- No extra MX on `ses.your-domain.com` that steals the name.

Then verify on the domain page.

## Related

- [How to verify a sending domain](/docs/deliverability/how-to-verify-sending-domain)
- [Email Domains](/docs/domains/email-domains)

---

# How to fix BIMI
Source: https://lumail.io/docs/deliverability/how-to-fix-bimi.md

You got **BIMI record is malformed**. This is informational. BIMI does not affect whether Lumail will send, and it is not required for sender health.

A BIMI record is present at `default._bimi.your-domain.com` but it is not a valid `v=BIMI1` record with an `https` logo URL.

## What to change

Either:

1. **Remove** the `default._bimi` TXT if you are not using BIMI. The alert goes away.
2. **Fix** it to a valid record:

| Type | Host | Value |
| --- | --- | --- |
| TXT | `default._bimi` | `v=BIMI1; l=https://yourdomain.com/logo.svg` |

The `l=` URL must be `https`, publicly reachable, and a SVG Tiny PS logo. Most senders also need a VMC before Gmail shows the logo. You can skip BIMI entirely.

## Related

- [Sender Health](/docs/features/sender-health)
- [How to add DMARC](/docs/deliverability/how-to-add-dmarc) — BIMI is ignored without an enforced DMARC policy

---

# How to lower bounce rate
Source: https://lumail.io/docs/deliverability/how-to-lower-bounce-rate.md

You got **Sustained permanent bounce rate is above 2%** or **above 5%**. Permanent bounces stayed at that share of **sent** mail across consecutive windows with enough volume. Low-volume days stay neutral and do not create this alert.

5% is an error. 2% is a warning. Both mean the list has addresses that do not exist (or reject the mailbox).

This is not inbox placement. It is hard failures after send.

## What to change

1. Open **Settings → Deliverability** and confirm the bounce windows.
2. Stop the next campaign/workflow send to the same dirty list.
3. Remove or suppress permanent-bounce addresses. Lumail already suppresses them after a permanent bounce — do not re-import them.
4. For the next send, exclude:
   - role accounts you never confirmed (`info@`, `admin@`) if they bounce
   - old exported lists
   - purchased or scraped addresses
5. Prefer confirmed opt-in for new subscribers.

If you imported a CSV, run a small verified segment first. Do not “clean” by blasting the whole file.

## What does not fix it

- Changing DKIM/DMARC. Auth issues are separate alerts.
- Sending more mail to dilute the rate. That makes enforcement worse.

## After it recovers

The alert resolves only after consecutive adequate windows stay **below** 2%. One good day is not enough.

## Related

- [How to lower complaint rate](/docs/deliverability/how-to-lower-complaint-rate)
- [Sender Health](/docs/features/sender-health)
- [Account Verification](/docs/features/account-verification)

---

# How to lower complaint rate
Source: https://lumail.io/docs/deliverability/how-to-lower-complaint-rate.md

You got **Sustained reported complaint rate is elevated** or **above 0.1%**. This uses ISP **feedback-loop** complaints only, as a share of **delivered** mail, across consecutive windows with enough volume.

It is not a complete inbox-placement score. Missing ISP coverage is not treated as a clean list.

0.1% is an error. ~0.08% is a warning. Inbox providers treat complaint rate as a primary reputation signal.

## What to change

1. Open **Settings → Deliverability** and inspect complaint windows.
2. Pause broad campaigns until you know who complained.
3. Make unsubscribe obvious and one-click. Buried unsubscribes become spam clicks.
4. Send to people who asked — drop purchased lists, old co-reg, and stale imports.
5. Tighten the audience: recent openers/clickers, or a confirmed segment, not the whole database.
6. Check subject/preview vs body. Surprise promo on a “receipt-looking” subject drives complaints.

Complained addresses are suppressed. Do not re-add them.

## After it recovers

The alert resolves after consecutive adequate windows stay below the warning threshold. Keep sending only to engaged, opted-in subscribers.

## Related

- [How to lower bounce rate](/docs/deliverability/how-to-lower-bounce-rate)
- [Content Deliverability Checker](/docs/features/content-deliverability)
- [Sender Health](/docs/features/sender-health)

---

# How to fix delivery delays
Source: https://lumail.io/docs/deliverability/how-to-fix-delivery-delays.md

You got **Sustained delivery delays are above 10% of sent mail**. Receiving servers accepted the message later (or greylisted / deferred it). This measures **delivery delay**, not inbox vs spam.

## What to change

Delays usually mean the far side is throttling you or retrying. Check, in order:

1. **Volume spike** — a sudden list blast after quiet weeks. Slow the send; don’t retry the same delayed cohort immediately.
2. **List quality** — high bounces/complaints in the same windows. Fix those first ([bounce](/docs/deliverability/how-to-lower-bounce-rate), [complaints](/docs/deliverability/how-to-lower-complaint-rate)).
3. **Authentication** — open domain alerts on **Settings → Deliverability**. Failed DKIM/SPF/DMARC makes deferrals more likely.
4. **Content** — run the [content checker](/docs/features/content-deliverability) on the campaign that was in-flight.
5. **Dedicated IP warmup** — if you use a dedicated IP, see [How to fix a dedicated IP](/docs/deliverability/how-to-fix-dedicated-ip).

You cannot force Gmail or Microsoft to stop greylisting. You can stop looking like a bursty or unauthenticated sender.

## After it recovers

The alert resolves when consecutive adequate windows stay below 10% delayed / sent.

## Related

- [Sender Health](/docs/features/sender-health)
- [Campaign Delivery Score](/docs/features/deliverability-score)

---

# How to fix blocklist bounces
Source: https://lumail.io/docs/deliverability/how-to-fix-blocklist-bounces.md

You got **Bounce diagnostics mention blocked-list or spam concentration**. Lumail classified enough permanent bounces (at least 10, and ≥ 2% of sent in the window) with blocked-list / spam-style diagnostic codes.

This is **not** a full public-blocklist monitor. It only uses bounce text Lumail already received.

## What to change

Treat it as a reputation or list problem, not a missing DNS row (unless you also have domain auth alerts).

1. Open **Settings → Deliverability** and read the bounce diagnostics.
2. Stop sending to the same unengaged list.
3. Remove the bouncing addresses — they are already suppressed; do not re-import.
4. If a specific destination (e.g. one consumer ISP) dominates, pause that segment and fix list quality before retrying.
5. Confirm SPF, DKIM, and DMARC are passing — [domain guides](/docs/deliverability). The [mail tester](/tools/mail-tester) checks all three plus the Spamhaus domain list on a real email.
6. If you recently moved from another ESP, expect leftover blocklist / filter memory. Warm up with engaged recipients only.

If the copy mentions a public list (Spamhaus, etc.), request delisting **only** after the list is clean and auth passes. Relisting is immediate if you keep sending the same traffic.

## After it recovers

The alert resolves when blocked-list bounce volume drops below the threshold (under 10 events or under 2% of sent on an adequate sample).

## Related

- [How to lower bounce rate](/docs/deliverability/how-to-lower-bounce-rate)
- [How to lower complaint rate](/docs/deliverability/how-to-lower-complaint-rate)
- [Sender Health](/docs/features/sender-health)

---

# How to fix sending infrastructure
Source: https://lumail.io/docs/deliverability/how-to-fix-sending-infrastructure.md

These alerts are **Lumail platform** checks (feedback topic, configuration set, account sending). You cannot fix them by editing DNS at your registrar.

| Alert | Meaning |
| --- | --- |
| Bounce and complaint feedback is not connected | The account feedback topic is missing on the sending identity. Bounce/complaint events stop reaching the deliverability ledger. |
| Email event tracking is disabled | The configuration set has sending disabled. New mail is rejected until it is enabled. |
| Configuration set event destination is unhealthy | Bounce, complaint, or delivery destinations are missing or disabled. Outcome metrics go stale. |
| Email sending is paused | Sending is paused at account level. This is not an inbox-placement finding. |

## What to do

1. Open **Settings → Deliverability** and confirm the alert is still open.
2. Do not rotate DNS or “re-add” the domain to try to fix this — that does not reconnect platform eventing.
3. Email [support@lumail.io](mailto:support@lumail.io) with the organization slug and the alert title. These rows are repaired on the sending account, not in your DNS host.

If sending is paused, campaigns and workflows will fail until the account is enabled again. Domain fallback does not bypass an account-level pause.

## Related

- [Sender Health](/docs/features/sender-health)
- [Email sending queue](/docs/features/email-sending-queue)

---

# How to fix a dedicated IP
Source: https://lumail.io/docs/deliverability/how-to-fix-dedicated-ip.md

You have dedicated IP enabled and Lumail opened one of:

- **Dedicated IP pool is missing** — enabled, but no sending pool is configured. Mail may fall back to shared infrastructure.
- **Dedicated IP is not ready** — warmup status is not `IN_PROGRESS` / `DONE`. Don’t push volume yet.
- **Dedicated IP warmup appears stalled** — status is `IN_PROGRESS` at **0%**. No observed volume through the pool.

## What to do

1. Open **Settings → Configuration → Dedicated IP**.
2. If the pool is missing, complete setup there or email [support@lumail.io](mailto:support@lumail.io) with the org slug. You cannot create the pool in DNS.
3. If warmup is not ready, keep volume low until status is done. A cold dedicated IP with a full-list blast will defer and bounce.
4. If warmup is stuck at 0%, confirm a real campaign/workflow is actually sending (not only tests to yourself). Tests and mailbox-simulator traffic do not warm the IP.
5. Do not switch back and forth between shared and dedicated mid-warmup.

## After it recovers

Pool-missing resolves when a pool name is configured. Not-ready resolves when warmup is in progress or complete. Stalled-at-0% resolves when warmup percentage moves or status completes.

## Related

- [How to fix delivery delays](/docs/deliverability/how-to-fix-delivery-delays)
- [Sender Health](/docs/features/sender-health)

---

# How to fix a failed send
Source: https://lumail.io/docs/deliverability/how-to-fix-email-send-failure.md

You got a send failure such as **Message rejected**, **Email delivery failed**, **Workflow email send failed**, or **campaign send failed**. The message never left, or the provider refused it.

## What to change

1. Open the linked campaign, workflow, or **Domains** page from the notification.
2. Read the last sentence of the alert — it names the provider code.
3. Fix the matching cause:

| Code / wording | Fix |
| --- | --- |
| MAIL FROM / `MailFromDomainNotVerifiedException` | [How to fix MAIL FROM](/docs/deliverability/how-to-fix-mail-from) |
| Domain not verified / identity paused | [How to verify a sending domain](/docs/deliverability/how-to-verify-sending-domain) |
| Invalid from/to/subject/headers | [How to fix an invalid email parameter](/docs/deliverability/how-to-fix-invalid-email) |
| Configuration set missing / sending paused | [How to fix sending infrastructure](/docs/deliverability/how-to-fix-sending-infrastructure) |
| Rate or daily quota | [How to fix sending rate or daily quota](/docs/deliverability/how-to-fix-sending-rate) |
| This recipient is temporarily rate limited / `RECIPIENT_RATE_LIMITED` | [Recipient rate guards](/docs/deliverability/recipient-rate-guards) |
| Internal error / “bug on our side” | Nothing on your DNS. Email [support@lumail.io](mailto:support@lumail.io) with the org slug and the technical detail. |
| Message rejected (generic) | Confirm the From address uses a **verified** custom domain, the recipient is not suppressed, and the campaign still renders. Then retry. |

4. After DNS changes, verify the domain, then resend or resume the campaign/workflow.

Fallback already delivered through another domain? See [How to fix a fallback-domain send](/docs/deliverability/how-to-fix-fallback-domain).

## Related

- [Email Domains](/docs/domains/email-domains)
- [How to fix a failed campaign](/docs/deliverability/how-to-fix-failed-campaign)

---

# How to fix a fallback-domain send
Source: https://lumail.io/docs/deliverability/how-to-fix-fallback-domain.md

You got **Email used fallback domain** or **Workflow email used fallback domain**. The recipient got mail, but **not** from the From domain you configured. Lumail retried through a fallback you set on **Domains**.

The display name and local part stay the same; only the domain changes (`Melvyn <hello@primary.com>` → `Melvyn <hello@fallback.com>`).

## What to change

1. Open **Domains**. The alert names the primary host and the fallback that was used.
2. Fix the **primary** domain — usually MAIL FROM, SES identity, or DKIM. Use the error sentence in the notification:
   - [How to fix MAIL FROM](/docs/deliverability/how-to-fix-mail-from)
   - [How to verify a sending domain](/docs/deliverability/how-to-verify-sending-domain)
   - [How to fix DKIM](/docs/deliverability/how-to-fix-dkim)
3. Verify the primary domain. New sends stop using the cached fallback (Lumail caches a successful fallback for 10 minutes for that org + primary).
4. If no fallback is configured and you want this safety net, set **Fallback** priority on a second verified domain.

Fallback does **not** fix account-level pause, quota, or a missing configuration set.

## Related

- [Email Domains](/docs/domains/email-domains) — fallback priorities
- [How to fix a failed send](/docs/deliverability/how-to-fix-email-send-failure)

---

# How to fix an invalid email parameter
Source: https://lumail.io/docs/deliverability/how-to-fix-invalid-email.md

You got **Invalid email parameter**. One field in the send was rejected (`from`, `to`, `subject`, or headers). This is a permanent reject for that attempt — fix the value, then send again.

## What to change

1. Open the campaign, workflow email, or transactional payload from the notification.
2. Check, in order:
   - **From** — must be `name@your-verified-domain`. No shared/test domain if that identity is gone. No spaces, angle-bracket typos, or unquoted display names that break the address.
   - **To** — a real mailbox. Role accounts are fine; empty/malformed addresses are not.
   - **Subject** — present. Extreme header injection (raw newlines) will fail.
   - **Reply-To / custom headers** — valid emails only.
3. If the From domain is unverified, [verify it](/docs/deliverability/how-to-verify-sending-domain) first.
4. Retry the send. Do not keep retrying the same broken From.

## Related

- [How to fix a failed send](/docs/deliverability/how-to-fix-email-send-failure)
- [Send Email API](/docs/api-reference/api-emails-send)

---

# How to fix sending rate or daily quota
Source: https://lumail.io/docs/deliverability/how-to-fix-sending-rate.md

You got **Sending rate exceeded**, **Daily sending quota reached**, or **Urgent: daily sending limit reached**. There are two daily ceilings:

- **Throttling** — too many sends per second. Transient. Slow down; Lumail will retry eligible mail.
- **SES daily quota** — the provider’s 24-hour send ceiling. Wait for the window to roll, or ask us to raise it.
- **Lumail plan daily limit** — your organization’s daily sending allotment (for example 25,000 on Creator). A campaign larger than what remains today is sent up to that remaining count. The rest is not queued. Use **Contact admin** on the campaign or notification if you need the leftover recipients sent today.

## What to change

1. Stop launching another full-list campaign on top of one already draining the quota.
2. Let in-flight retries finish. Don’t hammer “Send” on the same campaign.
3. If this is normal volume for you, email [support@lumail.io](mailto:support@lumail.io) or open **Contact admin** with the org slug and approximate daily volume. We raise the provider or plan cap; you cannot do it in DNS.
4. If you also see **monthly** Lumail limit alerts, that’s a different ceiling — [How to fix a monthly email limit](/docs/deliverability/how-to-fix-monthly-email-limit).
5. If the message is **This recipient is temporarily rate limited**, that is one mailbox, not the account send rate — [Recipient rate guards](/docs/deliverability/recipient-rate-guards).

## Related

- [Email sending queue](/docs/features/email-sending-queue)
- [How to fix a failed send](/docs/deliverability/how-to-fix-email-send-failure)
- [Recipient rate guards](/docs/deliverability/recipient-rate-guards)

---

# Recipient rate guards
Source: https://lumail.io/docs/deliverability/recipient-rate-guards.md

Lumail blocks a **single recipient address** from receiving too many real emails, even if the sender is allowed to send. Campaigns, workflows, transactional API, and SMTP share this gate. It is not your plan quota and not the HTTP API rate limit.

The address is hashed (SHA-256 of the normalized envelope) before it is stored in Redis. Fixture / test-mode recipients skip the gate entirely — see [Test-mode recipients](/docs/api-reference/test-mode).

## Caps

| Rule            | Limit                 | Window                                                     |
| --------------- | --------------------- | ---------------------------------------------------------- |
| Frequency       | **5** accepted sends  | rolling **10 minutes**                                     |
| Frequency       | **20** accepted sends | rolling **24 hours**                                       |
| Bounce cooldown | no further sends      | **24 hours** after a recipient-rate or mailbox-full bounce |

A send counts only when the SES-boundary grant succeeds. Peeking the windows (API preflight) does not increment them.

The 24-hour frequency cap is the tighter one for a blast to one inbox: **at most 20 real emails per address per day**.

## What trips the 24-hour cooldown

A later bounce in these categories writes a per-recipient cooldown key. Until it expires, every send to that address is refused:

- Gmail / ISP **recipient rate throttled**
- **Mailbox full**

Coarse ISP labels (for example `Other`) never pause unrelated recipients. Guards are recipient-only.

## Who it applies to

| Path                                                                    | Guarded? | When it refuses                                                                      |
| ----------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------ |
| Transactional API (`POST /api/v1/emails`, `POST /api/v2/emails`, batch) | Yes      | **Before queue** if the window is already full. **At send** if a burst queued first. |
| SMTP (`smtp.lumail.io`)                                                 | Yes      | Same transactional lane and the same Redis windows.                                  |
| Campaigns and workflow emails                                           | Yes      | **At send.** Already-enrolled recipients that hit the cap are skipped, not retried.  |
| Test-mode / reserved fixture domains                                    | No       | Fake SES id, no window increment.                                                    |

Transactional traffic is `TIME_SENSITIVE`: it jumps ahead of campaign bulk for the shared SES limiter. The **recipient** caps still apply. A 20k burst to one Gmail does not starve the guard, but while those rows drain or skip they can delay bulk campaigns.

## What you see

### Transactional API — refused at enqueue

The address already sits at 5/10 min or 20/24 h (or is on cooldown). Lumail does **not** create the email or the journal row.

```json
{
  "message": "This recipient is temporarily rate limited",
  "code": "RECIPIENT_RATE_LIMITED",
  "retryAfter": 86400
}
```

HTTP **429**, plus `Retry-After` in seconds. Honor that header. Do not treat this as the organization API rpm limit — that 429 uses a different body (`Too many requests`) and is documented under [Rate Limits](/docs/api-reference/api-limits).

### Transactional / campaign — refused at send

A burst can pass preflight while the windows are still empty, then lose at the SES fence. Those rows become journal phase **`SKIPPED`** with `RECIPIENT_RATE_LIMITED`. No provider send. Campaigns do not retry them. Transactional placeholders stay unsent (`awsEmailId` null).

### One address, 20 000 transactional emails in two hours

1. The first **20** grants increment the 24-hour window and go to SES (subject to org quota and API rpm).
2. Call 21 onward: **429** `RECIPIENT_RATE_LIMITED` if you send after those 20 have been granted.
3. If you enqueue all 20 000 before any grant lands, ~20 send and the rest **`SKIPPED`**.
4. Gmail sees at most **20** messages. The rest never leave Lumail.
5. The 429 / skip lasts until the oldest grant ages out of the 24-hour window (up to 24 h).

Without an `Idempotency-Key`, each call is a distinct dispatch. You still get **one** subscriber row.

## Not this guard

| Symptom                                  | What it actually is                                                                                                  |
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `429` `Too many requests`                | Organization HTTP rpm. [Rate Limits](/docs/api-reference/api-limits).                                                |
| Monthly / daily plan cap                 | Lumail billing quota. [How to fix a monthly email limit](/docs/deliverability/how-to-fix-monthly-email-limit).       |
| Sending rate exceeded / daily SES quota  | Shared provider account cap. [How to fix sending rate or daily quota](/docs/deliverability/how-to-fix-sending-rate). |
| Slow campaign (for example ~10 emails/s) | Provider RTT and retries after the grant. Recipient guards are not a global emails/s cap.                            |

## Related

- [Test-mode recipients](/docs/api-reference/test-mode)
- [Send Email (v2)](/docs/api-reference/v2/emails-send)
- [Rate Limits](/docs/api-reference/api-limits)
- [How to fix sending rate or daily quota](/docs/deliverability/how-to-fix-sending-rate)
- [How to fix a failed send](/docs/deliverability/how-to-fix-email-send-failure)

---

# How to fix a monthly email limit
Source: https://lumail.io/docs/deliverability/how-to-fix-monthly-email-limit.md

You got **You are approaching your monthly email limit**, **Only N emails left this month**, or **Email sending is blocked** because the monthly quota was exceeded.

Campaigns and workflows **park**. New workflow triggers are not queued until sending is active again.

## What to change

1. Open **Settings → Billing → Plan** (the notification’s **Upgrade** / **Upgrade and resume** button).
2. Upgrade if you need to keep sending this period. After the plan change, sending resumes and parked mail is released.
3. If you stay on the current plan, sending resumes automatically at the monthly reset. You’ll get **Email sending resumed**.
4. Cut volume until then: pause remaining campaigns, don’t import-and-blast.

80% and 95% are warnings only. 100% blocks sending.

This is **not** the provider daily SES quota — that’s [How to fix sending rate or daily quota](/docs/deliverability/how-to-fix-sending-rate).

## Related

- [Email sending queue](/docs/features/email-sending-queue)
- [Account Verification](/docs/features/account-verification)

---

# How to fix a past-due payment pause
Source: https://lumail.io/docs/deliverability/how-to-fix-past-due-payment.md

You got **Email sending is blocked** because payment is past due. Campaigns and workflow runs are parked. New workflow triggers are not queued.

## What to change

1. Open **Settings → Billing → Plan** (or **Resolve billing** on the notification).
2. Pay the outstanding invoice / update the card.
3. When the plan is active again, Lumail resumes sending and releases parked campaign and workflow mail. You’ll get **Email sending resumed**.

DNS changes do not unblock this. Domain fallback does not bypass a billing pause.

If checkout fails, email [support@lumail.io](mailto:support@lumail.io) with the org slug.

## Related

- [How to fix a monthly email limit](/docs/deliverability/how-to-fix-monthly-email-limit)
- [How to fix an admin sending pause](/docs/deliverability/how-to-fix-admin-sending-pause)

---

# How to fix an admin sending pause
Source: https://lumail.io/docs/deliverability/how-to-fix-admin-sending-pause.md

You got **Email sending is blocked** with an admin suspension. Campaigns and workflows are parked. New workflow triggers are not queued.

This is not a domain, quota, or card problem.

## What to change

Email [support@lumail.io](mailto:support@lumail.io) with:

- organization slug
- that sending shows as admin-paused
- any context (recent complaints, a requested review)

Only an administrator can restore access. Paying an invoice or editing DNS will not.

## Related

- [How to complete additional verification](/docs/deliverability/how-to-complete-additional-verification)
- [Account Verification](/docs/features/account-verification)

---

# How to complete additional verification
Source: https://lumail.io/docs/deliverability/how-to-complete-additional-verification.md

You got **Additional verification required**, **Verification declined**, or **More info needed for verification**. Sending is restricted until verification is completed or updated.

## Additional verification required

A complaint or a bad sender-health window triggered a defensive review.

1. Open **Settings → Verification**.
2. Complete the form (website, social profile, list size, how you use Lumail).
3. Clean the list if you also have [bounce](/docs/deliverability/how-to-lower-bounce-rate) or [complaint](/docs/deliverability/how-to-lower-complaint-rate) alerts — approval won’t stick if you keep mailing a dirty list.

## More info needed

The notification already includes the question.

1. Open **Settings → Verification** → **Update info**.
2. Answer that question. Don’t resubmit the same empty form.

## Verification declined

Reply to the email we sent, or write [support@lumail.io](mailto:support@lumail.io), with the extra evidence they asked for (site, opt-in proof, list source). There is no self-serve “approve” button.

## First-time unlock (domain only)

If you were never verified, verifying a custom sending domain unlocks automatically (3,000/month). See [Account Verification](/docs/features/account-verification).

## Related

- [Sender Health](/docs/features/sender-health)
- [How to unlock sending](/docs/features/account-verification)

---

# Campaign content verification rules
Source: https://lumail.io/docs/deliverability/campaign-content-verification.md

Lumail checks a campaign before scheduling it. The check protects recipients and the sending reputation shared by every Lumail customer.

## When a check can block sending

For **HUMAN_VERIFIED**, **WHITELISTED**, and organizations retaining prior human approval, campaign AI findings are advisory. A failed check or an unavailable evaluator does not prevent scheduling, and no content rewrite or per-campaign admin override is required. This applies in the editor, API, and agent tools.

**AUTO_VERIFIED** and **AI_VERIFIED** organizations without prior human approval remain subject to blocking checks. An organization awaiting its first human review is not exempt. Other sending restrictions, unsubscribe requirements, and human confirmation still apply. The server's scheduling result determines whether sending is allowed; an AI warning alone does not.

## What Lumail does not support

Lumail does not support:

- **Cold email:** unsolicited messages to people who did not subscribe, request the message, buy from you, or otherwise establish a relevant relationship with your organization.
- **Phishing:** deceptive messages that impersonate a person or brand, hide the real sender, manufacture urgency, or try to obtain credentials, money, or sensitive information under false pretenses.

If the campaign is genuinely cold email or phishing, changing a few words does not make it eligible. Do not send it through Lumail. An imported, purchased, scraped, enriched, or publicly listed address is not proof of permission.

## What you can fix

A legitimate campaign can still need review when its context is unclear. Check that it:

1. Shows the organization or product name prominently.
2. Uses a sender identity and links that match that organization.
3. Tells recipients truthfully why they are receiving the message, such as signing up for a named list, requesting a resource, creating an account, or making a purchase.
4. Avoids misleading claims, impersonation, hidden destinations, and false urgency.

The reason-for-sending sentence must describe a real relationship. Adding “you subscribed” cannot cure a list whose recipients never subscribed.

## If the detection is wrong

Use **Explain why** in the blocked campaign dialog. The AI Assistant reads these rules and the current campaign, then points to the content that triggered the check. It can suggest clearer wording for a legitimate permission-based campaign. It will not provide a workaround for actual cold email or phishing.

An administrator may review a false positive for the exact campaign version. Editing the content or sender requires a new verification.

## Related

- [Content Deliverability Checker](/docs/features/content-deliverability)
- [Customer compliance](/docs/legal/customer-compliance)
- [How to complete additional verification](/docs/deliverability/how-to-complete-additional-verification)

---

# How to fix a failed campaign
Source: https://lumail.io/docs/deliverability/how-to-fix-failed-campaign.md

You got **Campaign failed**: processing finished, but only *N* of *M* emails were sent.

This is the batch-level outcome, not a single bounce. Recipients that failed stay unsent.

## What to change

1. Open the campaign from the notification.
2. Check **Settings → Deliverability** and **Domains** for open errors (identity, MAIL FROM, sending paused, quota). Fix those first with the matching how-to.
3. Open recipient/failure detail on the campaign. If the wording is SES MAIL FROM / rejected / quota, use:
   - [How to fix a failed send](/docs/deliverability/how-to-fix-email-send-failure)
   - [How to fix a monthly email limit](/docs/deliverability/how-to-fix-monthly-email-limit)
4. If sending was blocked mid-campaign, resume after the block lifts, then send again to the remaining audience (don’t re-send people who already got the email).
5. If almost nobody sent and you also got a **render** alert, fix the template first — [How to fix a campaign render error](/docs/deliverability/how-to-fix-campaign-render-error).

## Related

- [Campaign Delivery Score](/docs/features/deliverability-score)
- [Email sending queue](/docs/features/email-sending-queue)

---

# How to fix a campaign render error
Source: https://lumail.io/docs/deliverability/how-to-fix-campaign-render-error.md

You got **Campaign email has invalid HTML** or **Campaign email failed to render**. Lumail never handed the message to the email system. No recipients were sent for that attempt.

## What to change

1. Open the campaign from the notification.
2. **Invalid HTML** — a block or raw HTML snippet doesn’t parse. In the editor, remove recently pasted HTML, custom code blocks, or broken tags. Re-save and send a test to yourself.
3. **Failed to render** — usually a bad variable, snippet, or image/source the renderer can’t load. Check:
   - mustache variables with unmatched braces
   - snippets that were archived
   - image URLs that 404 or aren’t `https`
4. Send a test. When the test renders, send or resume the campaign.

DNS will not fix a render error.

## Related

- [Variables](/docs/features/variables)
- [Content Deliverability Checker](/docs/features/content-deliverability)
- [How to fix a failed campaign](/docs/deliverability/how-to-fix-failed-campaign)

---

# Deliverability alerts
Source: https://lumail.io/docs/deliverability.md

Every deliverability warning and error in Lumail links here. Open the matching guide, make the change, then recheck **Domains** or **Settings → Deliverability**.

This is not inbox placement. A delivered event means the receiving server accepted the message.

## Domain authentication

| Alert | What to change | Guide |
| --- | --- | --- |
| `example.com DMARC policy is monitoring only` | `_dmarc` TXT: `p=none` → `p=quarantine` or `p=reject` (on the root domain if inherited) | [How to strengthen DMARC](/docs/deliverability/how-to-strengthen-dmarc) |
| `example.com is missing DMARC` / `DMARC record is malformed` | Publish a valid `_dmarc` TXT | [How to add DMARC](/docs/deliverability/how-to-add-dmarc) |
| `example.com DKIM is missing, failed, or weak` | Add the DKIM CNAMEs from the domain page | [How to fix DKIM](/docs/deliverability/how-to-fix-dkim) |
| `example.com is not verified for sending` | Add the verification + SPF records | [How to verify a sending domain](/docs/deliverability/how-to-verify-sending-domain) |
| `example.com custom MAIL FROM MX/SPF is incomplete` | Add MX + SPF on `ses.example.com` | [How to fix MAIL FROM](/docs/deliverability/how-to-fix-mail-from) |
| `example.com BIMI record is malformed` | Fix or remove the default BIMI TXT | [How to fix BIMI](/docs/deliverability/how-to-fix-bimi) |

## Sending health

| Alert | What to change | Guide |
| --- | --- | --- |
| Sustained permanent bounce rate is above 2% / 5% | Remove invalid addresses before the next send | [How to lower bounce rate](/docs/deliverability/how-to-lower-bounce-rate) |
| Sustained reported complaint rate is elevated / above 0.1% | Stop mailing people who mark you as spam | [How to lower complaint rate](/docs/deliverability/how-to-lower-complaint-rate) |
| Sustained delivery delays are above 10% of sent mail | Check volume, reputation, and content | [How to fix delivery delays](/docs/deliverability/how-to-fix-delivery-delays) |
| Bounce diagnostics mention blocked-list or spam concentration | Treat it as a reputation / list problem | [How to fix blocklist bounces](/docs/deliverability/how-to-fix-blocklist-bounces) |

## Infrastructure

These are Lumail sending-platform checks, not DNS you edit at your registrar.

| Alert | Guide |
| --- | --- |
| Bounce and complaint feedback is not connected | [How to fix sending infrastructure](/docs/deliverability/how-to-fix-sending-infrastructure) |
| Email event tracking is disabled | [How to fix sending infrastructure](/docs/deliverability/how-to-fix-sending-infrastructure) |
| Configuration set event destination is unhealthy | [How to fix sending infrastructure](/docs/deliverability/how-to-fix-sending-infrastructure) |
| Email sending is paused | [How to fix sending infrastructure](/docs/deliverability/how-to-fix-sending-infrastructure) |
| Dedicated IP pool is missing / not ready / warmup stalled | [How to fix a dedicated IP](/docs/deliverability/how-to-fix-dedicated-ip) |

## Sends, billing, and verification

| Alert | What to change | Guide |
| --- | --- | --- |
| Domain verification failed | Add the DNS rows on the domain page | [How to verify a sending domain](/docs/deliverability/how-to-verify-sending-domain) |
| Sender domain DNS is incomplete / MAIL FROM | MX + SPF on `ses.example.com` | [How to fix MAIL FROM](/docs/deliverability/how-to-fix-mail-from) |
| Message rejected / send failed | Fix From, domain, or the named SES code | [How to fix a failed send](/docs/deliverability/how-to-fix-email-send-failure) |
| Invalid email parameter | Fix from / to / subject / headers | [How to fix an invalid email parameter](/docs/deliverability/how-to-fix-invalid-email) |
| Sending rate exceeded / daily quota | Slow down or ask for a higher cap | [How to fix sending rate or daily quota](/docs/deliverability/how-to-fix-sending-rate) |
| This recipient is temporarily rate limited | Wait for `Retry-After`, or stop blasting one inbox | [Recipient rate guards](/docs/deliverability/recipient-rate-guards) |
| Email used fallback domain | Fix the **primary** domain | [How to fix a fallback-domain send](/docs/deliverability/how-to-fix-fallback-domain) |
| Approaching / hit monthly email limit | Upgrade or wait for reset | [How to fix a monthly email limit](/docs/deliverability/how-to-fix-monthly-email-limit) |
| Sending blocked — payment past due | Pay the invoice | [How to fix a past-due payment pause](/docs/deliverability/how-to-fix-past-due-payment) |
| Sending blocked — unverified org | Verify the domain / complete the form | [How to unlock sending](/docs/features/account-verification) |
| Sending blocked — admin pause | Contact support | [How to fix an admin sending pause](/docs/deliverability/how-to-fix-admin-sending-pause) |
| Additional verification / declined / more info | Complete **Settings → Verification** | [How to complete additional verification](/docs/deliverability/how-to-complete-additional-verification) |
| Campaign blocked by content verification | Review the failed checks; cold email and phishing are unsupported | [Campaign content verification rules](/docs/deliverability/campaign-content-verification) |
| Campaign failed | Fix the underlying send/quota error, then retry remaining | [How to fix a failed campaign](/docs/deliverability/how-to-fix-failed-campaign) |
| Campaign email failed to render / invalid HTML | Fix the template, then test | [How to fix a campaign render error](/docs/deliverability/how-to-fix-campaign-render-error) |

## Related

- [Email Domains](/docs/domains/email-domains)
- [Sender Health](/docs/features/sender-health)
- [Campaign Delivery Score](/docs/features/deliverability-score)
- [Content Deliverability Checker](/docs/features/content-deliverability)

---

# Workflow
Source: https://lumail.io/docs/workflows/workflow.md

Workflow is Lumail's graph-based automation engine. Use it when a sequence needs branching, event waits, weighted splits, conversion goals, global exits, mutually exclusive groups, immutable published versions, dry runs, or a detailed execution timeline.

## Use this guide

| If you want to...                        | Read                                                           |
| ---------------------------------------- | -------------------------------------------------------------- |
| Build your first graph in the editor     | [Getting started](/docs/workflows/workflow-getting-started)    |
| Choose enrollment events and filters     | [Triggers](/docs/workflows/workflow-triggers)                  |
| Enroll a filtered list by hand           | [Manual enrollment](/docs/workflows/workflow-manual-enrollment) |
| Add conditions, event waits, or loops    | [Branching](/docs/workflows/workflow-branching)                |
| Compare two or more variants             | [A/B testing](/docs/workflows/workflow-ab-testing)             |
| Define and measure conversion            | [Goals and success goals](/docs/workflows/workflow-goals)      |
| Stop runs when subscriber state changes  | [Exit rules](/docs/workflows/workflow-exit-rules)              |
| Understand drafts and immutable versions | [Publishing and versions](/docs/workflows/workflow-publishing) |
| Test safely and inspect results          | [Test runs and timelines](/docs/workflows/workflow-testing)    |
| Send a real run without hitting inboxes  | [Test Workflow](/docs/workflows/test-workflow)                 |

![Workflow editor showing a complete graph and its configuration rails](/docs/workflows/editor-overview.png)

## Mental model

Every Workflow has four related layers:

| Layer             | Purpose                                                                                                                     |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------- |
| Workflow          | Organization-owned name, status, repeat settings, group assignment, exits, and current draft                                |
| Draft definition  | Editable complete `{ steps, edges }` graph; changing it does not change existing runs                                       |
| Published version | Immutable snapshot created from the draft; every publish increments the version                                             |
| Subscriber run    | One subscriber executing one published version with its own status, current step, schedule, context, metadata, and timeline |

Existing runs remain pinned to the version they started with. Editing or publishing a new draft never rewrites an existing run.

## Workflow and run statuses

Workflow statuses:

| Status     | Meaning                                                             |
| ---------- | ------------------------------------------------------------------- |
| `DRAFT`    | Never published; editable but cannot enroll subscribers             |
| `ACTIVE`   | Published and eligible for new enrollment and execution             |
| `PAUSED`   | Published but new execution is paused until reactivated             |
| `ARCHIVED` | Removed from normal operation while preserving versions and history |

Subscriber run statuses:

| Status      | Meaning                                                                      |
| ----------- | ---------------------------------------------------------------------------- |
| `ACTIVE`    | Executing or ready to execute a step                                         |
| `WAITING`   | Waiting for time, an event, audience changes, or sending access              |
| `COMPLETED` | Reached the end, a goal, or an explicit exit                                 |
| `CANCELED`  | Stopped by an exit rule, group conflict, manual removal, or lifecycle action |
| `FAILED`    | Stopped by an unrecoverable execution failure                                |

Run data includes `workflowVersionId`, `currentStepId`, `nextActionAt`, `runNumber`, `isDryRun`, `waitingForAudienceChange`, `context`, `metadata`, lifecycle timestamps, and `goalReachedId`. Timeline events record step starts/completions, decisions, waits, dry-run skips, exits, goals, and failures.

## Graph contract

The definition is a complete graph:

```json
{
  "steps": [],
  "edges": [],
  "exitMode": "DISABLED",
  "exitRules": [],
  "successGoals": []
}
```

Validation requires:

- Unique stable step and edge IDs
- At least one `TRIGGER`
- Every non-trigger step reachable from a trigger
- Every edge source and target to reference an existing step
- `CONDITION` steps to have `YES` and `NO` edges
- `WAIT_UNTIL` steps to have `MATCHED` and `TIMEOUT` edges
- `SPLIT` percentages to total 100
- `MOVE_TO_STEP` to target an existing different step
- `GOAL` steps to be terminal with no outgoing edge
- Empty exit rules to remain inactive until they contain a subscriber filter

The supported edge handles are `DEFAULT`, `YES`, `NO`, `MATCHED`, `TIMEOUT`, and `SPLIT_n` such as `SPLIT_1`.

## Trigger events

A trigger can listen to one `eventName` or multiple `eventNames`, plus subscriber audience filters and event-specific filters.

Supported events:

- `subscriber.created`
- `subscriber.updated`
- `subscriber.unsubscribed`
- `subscriber.tag.added`
- `subscriber.tag.removed`
- `subscriber.field.changed`
- `subscriber.email.sent`
- `subscriber.email.delivered`
- `subscriber.email.opened`
- `subscriber.email.clicked`
- `subscriber.email.bounced`
- `subscriber.payment`
- `subscriber.refund`
- `subscriber.capture_page.submitted`
- `subscriber.workflow.completed`
- `subscriber.workflow.goal_reached`

Event-filter keys are strict:

| Key          | Type               | Valid events                                        |
| ------------ | ------------------ | --------------------------------------------------- |
| `tagIds`     | Array of tag IDs   | Tag added and tag removed                           |
| `product`    | Product identifier | Payment and refund                                  |
| `campaignId` | Campaign ID        | Email sent, delivered, opened, clicked, and bounced |

Unknown keys are rejected. An omitted `eventFilters` object matches every event of the selected type.

## Step types

### `TRIGGER`

Starts enrollment from one or more subscriber events. Audience filters must match when the event is evaluated.

```json
{
  "id": "trigger",
  "type": "TRIGGER",
  "config": {
    "eventName": "subscriber.tag.added",
    "eventFilters": { "tagIds": ["tag_123"] },
    "filters": []
  }
}
```

### `EMAIL`

Sends the internal workflow email owned by that exact workflow step. The stored graph references its `campaignId`, but agent-built drafts should provide complete inline email content to `configure_workflow_draft`.

Workflow email rules:

- Lumail creates a dedicated campaign with `type: WORKFLOW`.
- One internal campaign belongs to exactly one workflow and step.
- A broadcast or another step's campaign is duplicated, never linked directly.
- Internal workflow campaigns do not appear in normal broadcast campaign lists.
- The run fetches current subscriber data before rendering.
- Sending uses the same durable bulk queue and organization quota as campaigns.
- Sending suspension parks the run instead of discarding it.

Never call `create_campaign` to create a Workflow email.

### `WAIT`

Pauses execution using one of three configurations:

- `DURATION`: positive duration in `minutes`, `hours`, or `days`
- `TIME`: next occurrence of a local `HH:mm` time
- `DAY`: next named weekday, optionally combined with time in the editor

Organization timezone controls calendar waits. For J0/J1/J3/J5 schedules, use incremental waits of 1, 2, and 2 days.

### `WAIT_UNTIL`

Waits for either audience filters (`mode: FILTER`) or a subscriber event (`mode: EVENT`). It can include a timeout duration and must route through both `MATCHED` and `TIMEOUT` edges.

Event mode uses the same strict event-filter keys as triggers. Filter mode wakes when subscriber data changes and the filters become true.

### `CONDITION`

Evaluates audience filters once and routes to `YES` or `NO`.

### `SPLIT`

Assigns subscribers to weighted branches. Each branch has a handle, percentage, and optional label; percentages must total exactly 100.

### `MOVE_TO_STEP`

Moves execution directly to another existing step ID. It cannot target itself or a missing step.

### `ACTION`

Supported action types:

| Action            | Data                                         | Behavior                                     |
| ----------------- | -------------------------------------------- | -------------------------------------------- |
| `ADD_TAGS`        | `tagIds`                                     | Adds tags and may trigger other workflows    |
| `REMOVE_TAGS`     | `tagIds`                                     | Removes tags                                 |
| `SET_FIELD`       | `fieldId`, `value`                           | Updates a subscriber field                   |
| `ADD_TO_WORKFLOW` | `workflowId`, optional `workflowStepId`      | Enrolls in an eligible workflow              |
| `UNSUBSCRIBE`     | None                                         | Unsubscribes the subscriber                  |
| `WEBHOOK`         | `url`, optional `secret`, optional `payload` | POSTs workflow context; `payload` is nested, never merged |

A terminal `ADD_TAGS` handoff is deferred until the emitting run completes, allowing First Wins groups to chain workflows safely. Non-terminal tags are evaluated immediately.

Webhook secrets are redacted from agent reads as `[REDACTED]`. That placeholder copies a **previously stored real** secret on a later graph update. First configure, or a new webhook step, must write the real secret or omit it — sending `[REDACTED]` is rejected when no real secret is stored. See [Webhook Step](/docs/workflows/workflow-webhook-step).

### `GOAL`

Marks a reusable workflow goal and completes the run. It must not have an outgoing edge. Agent configuration accepts an existing `goalId` or an inline `createGoal` object; goal creation is case-insensitively idempotent inside the same transaction.

### `EXIT`

Terminates one routed branch as exited and can store an optional machine-readable reason. This is different from a global exit rule that watches every run before configured step boundaries.

## Success goals

Top-level `successGoals` watch subscriber filters and optional events while a run is `ACTIVE` or `WAITING`.

Each success goal contains:

- Stable `id` and display `name`
- `goalId` or inline `createGoal`
- Audience `filters` (subscriber state)
- Optional `eventName` and strict `eventFilters` (which event)
- `completeWorkflow`

`eventName` + `eventFilters` select the event. `filters` is subscriber state. They are not interchangeable. Empty `filters` is fine only when a tag-added / tag-removed event also pins `eventFilters.tagIds`. Do not copy the trigger advice “don’t repeat the tag as an audience filter” onto success goals.

If the conversion is that the subscriber now has a tag, prefer `eventName` omitted plus a TAG `BELONGS_TO_ANY` filter. If you watch `subscriber.tag.added` itself, pin `eventFilters.tagIds`. Omitted or empty `tagIds` matches every tag mutation; `completeWorkflow: true` then completes every active run.

Filters inside one success goal use AND semantics. Goals are evaluated in array order and the first match is recorded. With `completeWorkflow: false`, Lumail records the conversion and keeps the run active. With `true`, it records the goal and completes the run.

## Global exits

`exitMode` controls when `exitRules` are evaluated:

| Mode               | Evaluation point              |
| ------------------ | ----------------------------- |
| `DISABLED`         | Never                         |
| `BEFORE_EMAIL`     | Immediately before each email |
| `BEFORE_EACH_STEP` | Before every step             |

Filters inside one rule use AND semantics; multiple rules use OR semantics. A match cancels the run before the step executes. An enabled mode with no configured filters is valid but inactive: it never exits a subscriber until at least one rule contains a filter.

## Repeat settings

`allowRepeats` controls whether the same subscriber may start the workflow again. `repeatCooldownMinutes` sets the minimum delay between runs and defaults to 1,440 minutes. Active-run deduplication still prevents simultaneous duplicate runs in the same workflow.

## Workflow groups

Groups make workflows mutually exclusive for a subscriber. Conflicts are matched by organization, subscriber, and group membership across active or waiting runs.

| Strategy          | Label                 | Result                                                        |
| ----------------- | --------------------- | ------------------------------------------------------------- |
| `CANCEL_EXISTING` | Latest wins           | Cancel existing conflicting runs, then start the new workflow |
| `SKIP_NEW`        | First wins            | Keep the existing run and skip the incoming enrollment        |
| `RUN_FIRST_STEP`  | Send first email only | Skip the incoming run but send its first email once           |

Group assignment affects future enrollments. Detaching a workflow or deleting its group does not alter or cancel existing runs. See [Workflow Groups](/docs/workflows/workflow-groups) for chaining and agent tools.

## Drafts, publishing, and status changes

Saving the draft changes only editable state. Publishing validates the complete graph and snapshots it as a new immutable version.

- First publish normally activates a `DRAFT` workflow unless `activate: false` is explicit.
- Publishing an already active workflow never deactivates it.
- Publishing a paused workflow never silently reactivates it.
- Activation, pausing, archiving, and publishing are separate lifecycle decisions.
- Existing runs continue on their enrolled version.

Publishing and status changes are confirmation-gated through MCP and the Tools API because activation may send emails or mutate subscribers.

## Configure a complete draft with an agent

The safe sequence is:

1. Call `get_skill` with `{ "type": "workflow" }`.
2. Call `get_workflow` and preserve its exact `updatedAt`.
3. Discover referenced tags, fields, senders, snippets, goals, workflows, and groups with read-only tools.
4. Call `configure_workflow_draft` once with complete `steps` and `edges` arrays.
5. Read the returned definition and `configuredEmails` mapping.
6. Read and render every internal email.
7. Publish separately only after explicit human authorization.

`configure_workflow_draft` is atomic and never publishes, activates, enrolls, or sends. A timeout or rollback leaves the draft unchanged. Retry the same complete payload only after a fresh read and updated concurrency value.

```json
{
  "workflowId": "workflow-id",
  "expectedUpdatedAt": "2026-07-27T10:00:00.000Z",
  "settings": {
    "name": "Welcome journey",
    "allowRepeats": false,
    "groupId": "group-id",
    "exitMode": "BEFORE_EACH_STEP",
    "exitRules": [
      {
        "id": "exit-unsubscribed",
        "name": "Customer tag present",
        "filters": [
          {
            "type": "TAG",
            "field": "tags",
            "operator": "BELONGS_TO_ANY",
            "tagIds": ["tag-customer"]
          }
        ]
      }
    ]
  },
  "steps": [
    {
      "id": "trigger",
      "type": "TRIGGER",
      "config": { "eventName": "subscriber.created", "filters": [] }
    },
    {
      "id": "email-welcome",
      "type": "EMAIL",
      "name": "Welcome",
      "email": {
        "subject": "Welcome aboard",
        "preview": "Your first steps",
        "content": {
          "type": "doc",
          "content": [
            {
              "type": "paragraph",
              "content": [{ "type": "text", "text": "Welcome!" }]
            }
          ]
        }
      }
    }
  ],
  "edges": [
    {
      "id": "edge-trigger-email",
      "source": "trigger",
      "target": "email-welcome",
      "sourceHandle": "DEFAULT"
    }
  ]
}
```

Use stable step and edge IDs. Stable IDs let retries update the same internal email instead of creating replacements.

## Workflow tools

Organizations receive these tools through the in-app assistant, MCP, SDK, CLI, and Tools API:

| Tool                              | Purpose                                                                         |
| --------------------------------- | ------------------------------------------------------------------------------- |
| `list_workflows`                  | List workflows with status, group, publication, and active-run data             |
| `get_workflow`                    | Read the complete draft, group, settings, goals, version, and concurrency value |
| `create_workflow`                 | Create an empty draft                                                           |
| `configure_workflow_draft`        | Atomically configure graph, emails, goals, and settings                         |
| `update_workflow_draft`           | Replace an existing complete graph                                              |
| `publish_workflow`                | Publish an immutable version; confirmation-gated                                |
| `update_workflow_status`          | Activate, pause, or archive; confirmation-gated                                 |
| `add_subscriber_to_workflow`      | Manually enroll one eligible subscriber                                         |
| `add_subscribers_to_workflow`     | Manually enroll a filtered set (Select all). Use `dryRun: true` first           |
| `remove_subscriber_from_workflow` | Cancel active runs for a subscriber                                             |
| `get_subscriber_workflow_runs`     | Inspect a subscriber's runs: status, current step, `nextActionAt`               |
| `fast_forward_workflow_subscriber` | Expire the current WAIT / WAIT_UNTIL and resume now. One wait per call          |
| `delete_workflow`                 | Delete an unused draft or archive history; confirmation-gated                   |
| `list_workflow_groups`            | Discover groups, strategies, IDs, and workflow counts                           |
| `get_workflow_group`              | Inspect workflows, active runs, strategy, and concurrency                       |
| `create_workflow_group`           | Idempotently create a group                                                     |
| `update_workflow_group`           | Rename or change strategy with optimistic concurrency                           |
| `set_workflow_group`              | Assign or detach a workflow without replacing its graph                         |
| `delete_workflow_group`           | Safely delete and optionally detach assigned workflows; confirmation-gated      |

The live schema is authoritative:

```bash
lumail tools get configure_workflow_draft --account <org>
lumail tools get create_workflow_group --account <org>
lumail tools get set_workflow_group --account <org>
```

## Dry runs, timelines, and observability

The editor can start an isolated dry run for a subscriber. Dry runs evaluate routing and record timeline events while skipping real email and action side effects.

Use the workflow dashboard and run timeline to inspect:

- Enrollment and run status
- Current step and next action time
- Branch decisions and wait reasons
- Email/action dry-run skips
- Goal and exit events
- Failures and stuck runs
- Active counts by step

Dry runs are excluded from production active-run metrics.

## Verification checklist

Before claiming a Workflow change is complete:

1. Read `get_workflow` again and compare `updatedAt`, steps, edges, settings, goals, group, and `publishedVersionId`.
2. Read every `configuredEmails` campaign and compare sender, subject, preview, and TipTap content.
3. Render every internal email and require non-empty HTML/text plus an unsubscribe link.
4. Confirm internal `WORKFLOW` campaigns do not appear as broadcasts.
5. Reconstruct incremental wait timing and every branch path.
6. Read group data again after group changes and verify conflict scope.
7. For draft-only work, prove version and publication timestamps did not change.
8. For published work, verify the intended version and status separately.
9. Inspect run timelines and server logs for runtime errors.

## Related documentation

- [Getting started](/docs/workflows/workflow-getting-started)
- [Triggers](/docs/workflows/workflow-triggers)
- [Manual enrollment](/docs/workflows/workflow-manual-enrollment)
- [Branching](/docs/workflows/workflow-branching)
- [A/B testing](/docs/workflows/workflow-ab-testing)
- [Goals and success goals](/docs/workflows/workflow-goals)
- [Exit rules](/docs/workflows/workflow-exit-rules)
- [Publishing and versions](/docs/workflows/workflow-publishing)
- [Test runs and timelines](/docs/workflows/workflow-testing)
- [Test Workflow](/docs/workflows/test-workflow)
- [Workflow Groups](/docs/workflows/workflow-groups)
- [Email Step](/docs/workflows/workflow-email-step)
- [Wait Step](/docs/workflows/workflow-wait-step)
- [Action Step](/docs/workflows/workflow-action-step)
- [Webhook Step](/docs/workflows/workflow-webhook-step)
- [MCP](/docs/api-reference/mcp)
- [CLI](/docs/api-reference/cli)
- [Tools API](/docs/ai-integration/tools-api)

---

# Workflow Getting Started
Source: https://lumail.io/docs/workflows/workflow-getting-started.md

Workflow separates editing from execution. You build and save a draft graph, test it without side effects, and publish an immutable version only when every path is ready.

## Before you start

Prepare the resources the graph will reference:

- A verified sender for every email step
- Tags or fields used by triggers, conditions, goals, or exits
- A clear entry event, such as a subscriber being created or receiving a tag
- One measurable definition of success
- A decision about whether the same subscriber may enroll again

## Create a Workflow draft

1. Open **Workflow** from the organization sidebar.
2. Select **Create workflow**.
3. Choose a template or start from an empty graph.
4. Give the workflow a name that describes the audience and outcome.
5. Keep it in **Draft** while building.

A draft cannot enroll subscribers. Saving changes the editable graph only; it does not send email or replace the currently published version.

## Understand the editor

![Workflow editor with the graph canvas, toolbar, and configuration rails](/docs/workflows/editor-overview.png)

The editor has three working areas:

| Area              | What it controls                                                 |
| ----------------- | ---------------------------------------------------------------- |
| Canvas            | Steps, routes, and the order in which subscribers move           |
| Step panel        | The selected trigger, email, wait, branch, action, goal, or exit |
| Workflow settings | Name, repeats, group, global exit rules, and success goals       |

The canvas also surfaces **Exit conditions** in the upper-right and **Success goals** in the lower-right so global behavior stays visible while editing the graph.

## Add and connect steps

Click the plus control after a step or on a branch placeholder. Lumail shows only supported workflow step types.

![Add-step menu with email, wait, branch, action, goal, and exit choices](/docs/workflows/add-step-menu.png)

A practical first sequence is:

1. **Trigger** — Subscriber created
2. **Email** — Welcome
3. **Wait** — 1 day
4. **Condition** — Subscriber has the customer tag
5. **Yes** — Goal: Purchased
6. **No** — Email: Product reminder

Branching steps create explicit route placeholders. Replace each placeholder with the first real step for that path. A branch may end naturally, with an **Exit** step, or with a terminal **Goal** step.

## Configure workflow settings

Open **Settings** and review:

- **Allow repeats**: permits a new run after the cooldown; it never duplicates an already active run.
- **Cooldown minutes**: defaults to 1,440 minutes between runs.
- **Workflow group**: applies mutual-exclusion behavior to future enrollments.
- **Exit**: repeatedly stops runs whose subscriber state now matches an exit rule.
- **Goals**: records conversions used by workflow and A/B results.

See [Workflow Groups](/docs/workflows/workflow-groups), [Goals](/docs/workflows/workflow-goals), and [Exit Rules](/docs/workflows/workflow-exit-rules) before enabling those settings.

## Save, test, then publish

Use this order:

1. Save the draft.
2. Resolve every validation message.
3. Open **Test run** and choose one subscriber.
4. Inspect the dry-run timeline and every branch decision.
5. Send tests for each workflow email and verify links, variables, sender, and unsubscribe footer.
6. Publish as a new version.
7. Activate only when new subscribers should begin enrolling.

Publishing and activation are separate decisions. Read [Publishing and Versions](/docs/workflows/workflow-publishing) for the exact lifecycle.

## First-workflow checklist

- Every non-trigger step is reachable.
- Every condition has **Yes** and **No** paths.
- Every event wait has **Matched** and **Timeout** paths.
- A/B percentages total exactly 100%.
- Every goal step is terminal.
- Every email has a verified sender and an unsubscribe link.
- Waits use incremental timing, not absolute campaign-day numbers.
- Exit rules and success goals use the intended AND/OR logic.
- A dry run reaches the expected terminal state on every path.

## Next steps

- [Configure triggers](/docs/workflows/workflow-triggers)
- [Manually enroll subscribers](/docs/workflows/workflow-manual-enrollment)
- [Build branches](/docs/workflows/workflow-branching)
- [Create an A/B test](/docs/workflows/workflow-ab-testing)
- [Test runs and inspect timelines](/docs/workflows/workflow-testing)
- [Send fixture addresses without hitting inboxes](/docs/workflows/test-workflow)

---

# Workflow Triggers
Source: https://lumail.io/docs/workflows/workflow-triggers.md

A trigger decides when Lumail evaluates a subscriber for enrollment. The event must match first; any subscriber filters on the trigger must also match at that moment.

## Configure a trigger

Select a trigger node to open its configuration panel.

![Trigger configuration with selected events and audience filters](/docs/workflows/trigger-events.png)

1. Choose one or more **Events**.
2. Add event-specific details when the event supports them.
3. Add subscriber filters only when the person must already match an audience rule at enrollment time.
4. Save the draft.

## Supported events

| Category        | Events                                         |
| --------------- | ---------------------------------------------- |
| Subscriber      | Created, updated, unsubscribed                 |
| Tags and fields | Tag added, tag removed, field changed          |
| Email           | Sent, delivered, opened, clicked, bounced      |
| Surveys         | Survey answered (`subscriber.survey.answered`) |
| Commerce        | Payment, refund                                |
| Capture pages   | Capture page submitted                         |
| Workflows       | Workflow completed, workflow goal reached      |

Use the narrowest event that represents the real entry point. For example, prefer **Tag added** with a specific tag over the broad **Subscriber updated** event.

## Event filters

Event filters describe the event payload. They are different from subscriber filters, which inspect the subscriber's current data.

| Event                                              | Supported event filter                                             |
| -------------------------------------------------- | ------------------------------------------------------------------ |
| Tag added or removed                               | One or more tag IDs                                                |
| Payment or refund                                  | Product identifier                                                 |
| Email sent, delivered, opened, clicked, or bounced | Campaign ID                                                        |
| Survey answered                                    | `surveyId`, optionally `optionId` (`optionId` requires `surveyId`) |

Leaving a survey trigger unscoped starts a run for **every** survey answer in
the organization, including surveys created later. Pin `surveyId`. The first
genuine answer fires once; changing the answer later does not re-enrol.

See [Surveys](/docs/features/surveys) for the option-id stability rules.

Unknown filter keys are rejected. Leaving the event filter empty matches every event of that selected type.

## Audience filters

Audience filters are checked when the trigger event is processed. Conditions in the same group must all match (AND). Different groups are alternatives (OR); an omitted `group` means group 0.

Examples:

- Tag added is `trial-started` **and** country is France
- Payment event is for `course-pro` **and** lifetime revenue is at least 100
- Capture page submitted **and** subscriber does not already have the customer tag

For a tag-added trigger, you usually do not need to repeat the same tag as an audience filter. The event itself already proves that tag was added. That advice is trigger-only. Success goals are a different contract: pin `eventFilters.tagIds` and/or a TAG subscriber-state filter. See [Goals](/docs/workflows/workflow-goals).

## Multiple events in one trigger

One trigger may listen to several events. This is useful when the same entry path should start from equivalent signals, such as payment or a customer tag added by an integration.

All selected events share the trigger's subscriber filters. Event-specific filters apply only to compatible events.

## Multiple trigger nodes

A graph may contain several trigger nodes. Use separate triggers when entry points need different event filters or subscriber rules, then route them to a shared first step.

Every non-trigger step must be reachable from at least one trigger. Trigger nodes do not need incoming edges.

## Enrollment guards still apply

A matching trigger does not guarantee a new run. Lumail still checks:

- The workflow is active and has a published version
- The subscriber is eligible to receive workflow processing
- No active duplicate run exists
- Repeat cooldown rules
- Workflow-group conflict strategy

See [Workflow Groups](/docs/workflows/workflow-groups) for **Latest wins**, **First wins**, and **Send first email only** behavior.

To enroll a filtered list without matching these trigger rules, use
[manual enrollment](/docs/workflows/workflow-manual-enrollment). That path
skips trigger events and trigger audience filters. It does not skip exit
rules, repeat cooldown, or group conflicts.

## Recommended patterns

| Need                               | Recommended trigger                                             |
| ---------------------------------- | --------------------------------------------------------------- |
| Welcome every new contact          | Subscriber created                                              |
| Start a funnel from an integration | Tag added with the integration tag                              |
| Follow up after checkout           | Payment with a product filter                                   |
| React to engagement                | Email clicked with the exact campaign ID                        |
| Follow up on a survey answer       | Survey answered with `surveyId` (and `optionId` for one choice) |
| Chain funnel stages                | Workflow completed or a terminal Add Tags action                |

## Related documentation

- [Getting Started](/docs/workflows/workflow-getting-started)
- [Manual enrollment](/docs/workflows/workflow-manual-enrollment)
- [Surveys](/docs/features/surveys)
- [Branching](/docs/workflows/workflow-branching)
- [Action Step](/docs/workflows/workflow-action-step)
- [Workflow Groups](/docs/workflows/workflow-groups)

---

# Workflow Manual Enrollment
Source: https://lumail.io/docs/workflows/workflow-manual-enrollment.md

You can enroll people by hand from the subscribers list. Use this when you want
a filtered audience to start now, to enter at a specific step, or to run a
published workflow without waiting for a trigger event.

The workflow starts immediately after you confirm. It does **not** evaluate the
trigger events or trigger audience filters that normally decide who may enter.
It still applies every normal exit rule once the run is active.

## Filter the list, then Select all

1. Open **Subscribers**.
2. Add the audience filters you want. Search and status also apply.
3. Click **Select all N subscribers**. This selects every subscriber matching
   the current filters, not only the current page.
4. Open **Actions** and choose **Add to workflow**.

![Filtered subscribers list with an email filter and the Select all control](/docs/workflows/manual-enrollment-filtered-list.png)

**Select all** is the control that targets the full filtered set. Ticking rows
by hand only enrolls the current page.

![Select all banner after every matching subscriber is selected](/docs/workflows/manual-enrollment-select-all.png)

![Actions menu with Add to workflow](/docs/workflows/manual-enrollment-actions-menu.png)

## Choose the workflow and starting step

The dialog asks for two things:

![Add to workflow dialog with workflow and optional starting step](/docs/workflows/manual-enrollment-dialog.png)

1. Choose an **active, published** workflow. Drafts do not appear.
2. Optionally choose **Start from Step**. Leave it empty to start at the
   trigger.
3. Click **Add to workflow**.

![Workflow picker listing active published workflows](/docs/workflows/manual-enrollment-choose-workflow.png)

Lumail then enrolls each matching subscriber and dispatches the run. Eligible
subscribers begin executing without another click and without a trigger event.

Starting step is optional. Use it to drop someone into a later email, wait, or
branch. Omit it when the whole sequence should run from the beginning.

## What this bypasses

Manual enrollment skips the trigger configuration on the published graph:

- Trigger events such as subscriber created, tag added, or payment
- Event filters such as a specific tag, campaign, or product
- Trigger audience filters — the subscriber rules on the trigger node

Those rules decide who may enter automatically. They are not re-checked when
you add people from the subscribers list. That is intentional: you already
chose the audience with the list filters.

An **Add to workflow** action step inside another workflow behaves the same way
for the target workflow: it enrolls without matching the target's trigger.

## What this does not bypass

Once the run exists, it is a normal run of the published version.

**Exit rules still apply.** Before each guarded step, Lumail evaluates the
published `exitMode` and `exitRules`. A match cancels the run and records an
exited timeline event. Manual enrollment does not skip that check.

These enrollment guards also still apply:

| Guard                         | Result if it fails                                         |
| ----------------------------- | ---------------------------------------------------------- |
| Workflow is not active        | Enrollment is skipped                                      |
| No published version          | Enrollment is skipped                                      |
| Subscriber is not subscribed  | Enrollment is skipped                                      |
| An active run already exists  | Enrollment is skipped                                      |
| Repeat cooldown is blocking   | Enrollment is skipped                                      |
| Workflow-group conflict       | Follows **Latest wins**, **First wins**, or **Send first email only** |
| Organization sending is off   | Enrollment is skipped                                      |

Condition steps, waits, wait-until, goals, exit steps, and success goals all
still execute as published. Manual enrollment only skips the *entry* rules, not
the *exit* or in-graph rules.

See [Exit Rules](/docs/workflows/workflow-exit-rules) and
[Workflow Groups](/docs/workflows/workflow-groups).

## Do this from an agent

Agents should not click the subscribers table. Use the same enrollment engine
as **Select all**:

1. Call `get_skill` with `{ "type": "filters" }` if you need filter shapes.
2. Call `count_subscribers` or `add_subscribers_to_workflow` with
   `dryRun: true` to preview the match count and published steps.
3. Call `add_subscribers_to_workflow` with the same filters and an optional
   `stepId`.
4. For one person, call `add_subscriber_to_workflow` with `email` or
   `subscriberId`.
5. To inspect where they are, call `get_subscriber_workflow_runs`. To expire
   the current WAIT or WAIT_UNTIL so the next email or action runs now, call
   `fast_forward_workflow_subscriber`. One call completes one wait.
   WAIT_UNTIL follows TIMEOUT.

```json
{
  "workflowId": "workflow-id",
  "dryRun": true,
  "filters": [
    {
      "type": "STRING",
      "field": "email",
      "operator": "CONTAINS",
      "value": "melvynmal"
    }
  ]
}
```

Read this page from a tool with
`get_skill({ "type": "docs", "page": "workflows/workflow-manual-enrollment" })`.
Call `get_skill({ "type": "docs" })` with no page to list every docs slug.

## When to use this

| Need                                          | Use manual enrollment                    |
| --------------------------------------------- | ---------------------------------------- |
| Start a published sequence for a segment now  | Filter → Select all → Add to workflow    |
| Enter at a later email or wait                | Set **Start from Step**                  |
| Bypass a trigger that is too narrow           | Yes — that is the purpose                |
| Ignore exit rules for customers               | No — configure or disable the exit rules |

For a side-effect-free path, use a [test run](/docs/workflows/workflow-testing)
instead. Dry runs skip emails and actions; manual enrollment is a real run.

## Related documentation

- [Triggers](/docs/workflows/workflow-triggers)
- [Exit Rules](/docs/workflows/workflow-exit-rules)
- [Publishing and Versions](/docs/workflows/workflow-publishing)
- [Test Runs and Timelines](/docs/workflows/workflow-testing)
- [Workflow Groups](/docs/workflows/workflow-groups)
- [Action Step](/docs/workflows/workflow-action-step)

---

# Workflow Branching
Source: https://lumail.io/docs/workflows/workflow-branching.md

Workflow makes every route explicit. Branching steps create named outgoing paths, while terminal steps deliberately end a path.

## Choose the right branching step

| Need                                    | Step           | Routes                          |
| --------------------------------------- | -------------- | ------------------------------- |
| Check subscriber state once             | Condition      | Yes / No                        |
| Wait for behavior or state change       | Wait for event | Matched / Timeout               |
| Randomly distribute traffic             | A/B test       | One route per weighted variant  |
| Rejoin or jump to another existing step | Move to step   | Direct target, no outgoing edge |
| End a successful path                   | Goal           | Terminal                        |
| End a non-success path                  | Exit           | Terminal                        |

## Condition

A condition evaluates the subscriber once when the run reaches it. Every filter in the condition must match for the **Yes** path; otherwise Lumail follows **No**.

![Condition step with explicit Yes and No routes](/docs/workflows/condition-branches.png)

Use a condition for facts that are meaningful at one moment, such as:

- Has the customer tag now?
- Is lifetime revenue above a threshold?
- Does the plan field equal `pro`?

Do not use a condition when the workflow should pause and wait for the answer to change. Use **Wait for event** instead.

## Wait for event

This step pauses the run until one of two outcomes:

- **Matched**: the selected event arrives or the audience filters become true.
- **Timeout**: the configured duration expires first.

Event mode can listen for payment, tag, field, email, capture-page, and workflow events. Audience mode wakes when relevant subscriber data changes.

Always design both routes. A missing business decision on the timeout path often leaves subscribers receiving content that no longer makes sense.

## A/B test

An A/B test distributes runs across weighted routes. It does not duplicate email content automatically: add the actual email, wait, or action for each variant.

Read [A/B Testing](/docs/workflows/workflow-ab-testing) for assignment, success attribution, and result interpretation.

## Move to step

Use **Move to step** to rejoin a shared sequence or intentionally loop to an earlier node. The target must already exist and cannot be the move step itself.

The runtime follows the configured target directly, so this step does not use a normal outgoing edge. Avoid unbounded loops: every loop should have a state change, wait, counter, or branch that eventually ends it.

## Goal and Exit

Both are terminal:

- **Goal** records conversion and completes the run.
- **Exit** completes that routed path as exited and records its optional reason.

Global exit rules are different. They repeatedly guard future steps regardless of the current route. See [Goals](/docs/workflows/workflow-goals) and [Exit Rules](/docs/workflows/workflow-exit-rules).

## Graph rules

- Every condition needs **Yes** and **No** edges.
- Every event wait needs **Matched** and **Timeout** edges.
- A/B percentages must total exactly 100%.
- Goal steps cannot have outgoing edges.
- Move-to-step targets must exist and cannot point to themselves.
- Every non-trigger node must be reachable from a trigger.
- A normal path may end without a terminal node when no next edge exists.

## Example: trial conversion path

1. Trigger when `trial-started` is added.
2. Send the onboarding email.
3. Wait up to seven days for a payment.
4. Route **Matched** to the `Purchased` goal.
5. Route **Timeout** to a condition that checks engagement.
6. Send a reminder on **Yes** and exit quietly on **No**.

This graph separates waiting for a future event from evaluating current state once.

## Related documentation

- [Wait Step](/docs/workflows/workflow-wait-step)
- [A/B Testing](/docs/workflows/workflow-ab-testing)
- [Goals](/docs/workflows/workflow-goals)
- [Exit Rules](/docs/workflows/workflow-exit-rules)

---

# Workflow A/B Testing
Source: https://lumail.io/docs/workflows/workflow-ab-testing.md

An A/B test is a weighted split in the workflow graph. Each subscriber run is assigned to one route, and the workflow dashboard compares conversion rates from your global success goals.

## What A/B testing does

- Assigns each run to exactly one weighted branch
- Keeps the assignment stable if that run retries the split step
- Lets each branch contain different emails, waits, actions, or subgraphs
- Attributes success when the run records a configured success goal

It does not create email variants automatically, calculate statistical significance, select a winner, or move future traffic to the leading branch.

## Add an A/B test

1. Click the plus control where the experiment should begin.
2. Choose **A/B test**.
3. Select the split node.
4. Name each variant by what actually differs.
5. Set percentages that total exactly 100%.
6. Replace each branch placeholder with its first real step.

![A/B test configuration with two named weighted variants](/docs/workflows/ab-test-config.png)

For two variants, the slider keeps both percentages complementary. Multi-variant tests expose a percentage for each route; the total indicator must reach 100% before publishing.

## Build useful variants

Change one meaningful variable at a time when you want an interpretable result:

| Test           | Variant A        | Variant B      |
| -------------- | ---------------- | -------------- |
| Subject line   | Benefit-led      | Curiosity-led  |
| Offer timing   | Send immediately | Wait one day   |
| Call to action | Book a call      | Start checkout |
| Nurture length | Two emails       | Four emails    |

Each path needs its own email step when email content differs. Do not point two branches at one email and expect separate content.

## Define success before publishing

A/B results use top-level **Success goals**, not merely reaching the end of a branch. Open **Settings > Goals** and define the same conversion signal for every variant.

Good success signals include:

- Payment for a specific product
- Customer tag added
- Booking field populated
- Minimum lifetime revenue reached
- A downstream workflow goal reached

Use `Complete workflow` only when conversion should stop all remaining nurture steps. Otherwise Lumail records the conversion and lets the run continue.

Read [Goals and Success Goals](/docs/workflows/workflow-goals) for event and filter semantics.

## How assignment works

Lumail derives a stable percentage from the run ID and split-step ID. A retry of the same run remains in the same variant. A later repeat enrollment creates a new run and may receive a different variant.

Changing weights affects runs that reach the newly published split version. Existing runs remain pinned to the version and definition with which they enrolled.

## Read the results

The workflow dashboard shows assigned runs, successful runs, and conversion rate for every branch.

![Workflow dashboard A/B success results with assignment and conversion counts](/docs/workflows/ab-test-results.png)

Conversion rate is:

```text
successful runs / assigned runs × 100
```

Only non-dry runs count. A run is successful when it has a recorded success goal. If several success goals could match, the first matching goal in workflow order is recorded.

## Interpret results carefully

- Wait until each variant has enough assignments for the difference to be meaningful.
- Compare the same time window and conversion definition.
- Account for delayed conversions before declaring a winner.
- Do not compare a newly edited variant against historical traffic from an older version without noting the version change.
- Check deliverability and click behavior when a conversion difference may be caused by email delivery rather than copy.

Lumail displays observed rates; it does not currently claim statistical significance.

## Common mistakes

| Mistake                                              | Result                                                   |
| ---------------------------------------------------- | -------------------------------------------------------- |
| Percentages do not total 100%                        | Publishing validation fails                              |
| No success goal                                      | Assignments appear, but every success count remains zero |
| Different conversion criteria per branch             | Rates are not comparable                                 |
| Several variables change at once                     | You cannot identify what caused the result               |
| Editing after launch without a new analysis boundary | Old and new variant behavior become mixed conceptually   |

## Related documentation

- [Branching](/docs/workflows/workflow-branching)
- [Goals and Success Goals](/docs/workflows/workflow-goals)
- [Publishing and Versions](/docs/workflows/workflow-publishing)
- [Test Runs and Results](/docs/workflows/workflow-testing)

---

# Workflow Goals
Source: https://lumail.io/docs/workflows/workflow-goals.md

Workflow uses one reusable goal definition in two different ways: a terminal **Goal step** inside the graph, or a top-level **Success goal** that watches subscriber events and state while the run is active.

## Choose the right goal

| Need                                      | Use              | Does the run complete?                   |
| ----------------------------------------- | ---------------- | ---------------------------------------- |
| The graph path itself proves success      | Goal step        | Always                                   |
| An external event proves conversion       | Success goal     | Only when `Complete workflow` is enabled |
| One route should stop without conversion  | Exit step        | Completes as exited, no goal             |
| Subscriber state should stop future steps | Global exit rule | Cancels before the guarded step          |

## Goal definitions

A goal definition has a name and optional description and belongs to one workflow. Examples are `Purchased`, `Booked a demo`, or `Activated account`.

The same definition can be referenced by graph goal steps and success goals. Goal names should describe the business outcome, not the UI action that detected it.

## Goal step

Add a **Goal** node when reaching a specific graph path proves success. Select or create the conversion goal in the step panel.

![Goal step configuration selecting the conversion reached by that path](/docs/workflows/goal-step.png)

When a run reaches the node, Lumail:

1. Records the goal on the run.
2. Emits a workflow-goal-reached event.
3. Marks the run completed.
4. Stops routing.

A goal step is terminal and cannot have an outgoing edge.

Use it after a step whose completion is itself the proof, such as an internal qualification path or a branch reached from a matched payment event.

## Success goals

Open **Settings > Goals** to define conversion signals that can occur while the run is active or waiting.

![Success goal settings with event, subscriber state, and completion behavior](/docs/workflows/success-goals.png)

Each success goal contains:

- A reusable goal definition
- A display name for the rule
- An optional subscriber event
- Event-specific filters when supported
- Subscriber-state filters
- A **Complete workflow** switch

The event and every subscriber filter must match together. Those are two layers:

| Layer            | Field                        | Editor control                                                                                           |
| ---------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------- |
| Which event      | `eventName` + `eventFilters` | Success event. The **Which tag changed** picker pins `eventFilters.tagIds` — it is not Subscriber state. |
| Subscriber state | `filters`                    | Subscriber state (AND within a group, OR between groups)                                                 |

For example:

- Event is **Payment**
- Product is `ai-builder-mobile`
- Subscriber has tag `qualified-lead`

If the conversion is "now has tag X", prefer subscriber state only. That is the visible pin:

```json
{
  "name": "Membre",
  "eventName": null,
  "eventFilters": null,
  "filters": [
    {
      "type": "TAG",
      "field": "tags",
      "operator": "BELONGS_TO_ANY",
      "tagIds": ["tag_member"]
    }
  ],
  "completeWorkflow": true
}
```

If you watch the tag-added event itself, pin `eventFilters.tagIds` (the event picker) **and** repeat the same tag in Subscriber state. Empty `eventFilters.tagIds` matches every tag mutation in the organization. With **Complete workflow** on, that completes every active or waiting run the next time any tag is added.

## Complete or continue

| Setting               | Behavior                                         |
| --------------------- | ------------------------------------------------ |
| Complete workflow off | Record conversion and continue the current route |
| Complete workflow on  | Record conversion and complete immediately       |

Turn completion on when later nurture would be inappropriate after conversion. Leave it off when the workflow should still deliver onboarding or fulfillment content.

## Evaluation order

Success goals are evaluated in the order shown. The first matching goal is recorded. Once a run has `goalReachedId`, later success goals are not recorded for that run.

Only real runs with status **Active** or **Waiting** are watched. Dry runs do not react to live external events and do not contribute to production conversion results.

## Success goals and A/B tests

The A/B dashboard treats a split assignment as successful when the same run records a success goal. Define the conversion before launching the experiment and keep it consistent across variants.

See [A/B Testing](/docs/workflows/workflow-ab-testing) for assignment and rate calculation.

## Goal events as triggers

Reaching a goal emits `subscriber.workflow.goal_reached`. Another Workflow trigger may use that event to start a downstream sequence. Use workflow groups when those sequences must never overlap.

## Naming recommendations

- Prefer outcomes: `Purchased Pro`, `Booked onboarding`, `Activated workspace`.
- Avoid implementation names: `Clicked button 3`, `Reached green node`.
- Keep one definition per business outcome.
- Use the rule name to explain detection details when needed.

## Related documentation

- [A/B Testing](/docs/workflows/workflow-ab-testing)
- [Exit Rules](/docs/workflows/workflow-exit-rules)
- [Triggers](/docs/workflows/workflow-triggers)
- [Test Runs and Results](/docs/workflows/workflow-testing)

---

# Workflow Exit Rules
Source: https://lumail.io/docs/workflows/workflow-exit-rules.md

Exit rules prevent subscribers from receiving steps that are no longer appropriate. They are global guards evaluated before configured step boundaries, independently of the path currently being followed.

## Global exit rule or Exit step?

| Need                                                       | Use              |
| ---------------------------------------------------------- | ---------------- |
| Stop anyone who becomes a customer before later nurture    | Global exit rule |
| Stop anyone who unsubscribes or enters an excluded segment | Global exit rule |
| End only the No branch of one condition                    | Exit step        |
| Record a successful terminal outcome                       | Goal step        |

An **Exit step** is part of the graph. A global exit rule can affect every route repeatedly.

## Configure exit rules

Open **Settings > Exit** or select the **Exit conditions** rail on the canvas.

![Exit settings with mode, named rules, and subscriber filters](/docs/workflows/exit-rules.png)

1. Choose an exit mode.
2. Add a named exit condition.
3. Add one or more subscriber filters to that condition.
4. Add another condition when a separate reason should also stop the run.
5. Save the draft and publish a new version.

## Exit modes

| Mode              | When rules are checked                      | Typical use                                                     |
| ----------------- | ------------------------------------------- | --------------------------------------------------------------- |
| Disabled          | Never                                       | No global early-exit behavior                                   |
| Before email step | Immediately before each email               | Prevent inappropriate email while allowing non-email processing |
| Before each step  | Before emails, waits, actions, and branches | Stop the run as soon as it next advances                        |

Changing the mode does not interrupt a run in the middle of a currently executing step. The published definition is checked at the next applicable boundary for that run.

## AND and OR logic

- Every filter inside one named condition uses **AND**.
- Separate named conditions use **OR**.

Example:

```text
(tag is customer AND country is France)
OR
(tag is do-not-nurture)
```

The subscriber exits if either complete condition matches.

## Empty conditions are inactive

You may enable an exit mode before defining its filters. A condition with no filters is ignored, and an enabled mode with no active conditions exits nobody.

This makes it safe to prepare the setting incrementally, but it also means the mode label alone does not prove protection is active. Confirm that at least one named condition contains the intended filters before publishing.

## Runtime behavior

When a rule matches, Lumail:

1. Stops before the guarded step executes.
2. Cancels the active run.
3. Records an **Exited** timeline event with the matched rule context.
4. Prevents remaining emails and actions in that run.

Existing runs use the exit rules from the published version on which they enrolled. Editing the draft does not retrofit new rules into those runs.

## Exit step

Add an **Exit** node to terminate one explicit graph route. It has no outgoing edge and may store a reason such as `Not qualified` or `No engagement after 14 days`.

Reaching an Exit step completes the run as exited. It does not create a conversion goal.

## Common patterns

### Stop nurture after purchase

- Mode: Before email step
- Rule: `Already purchased`
- Filter: Customer tag belongs to any of `customer`, `paid`

### Stop all processing after opt-out

- Mode: Before each step
- Rule: `Do not nurture`
- Filter: Tag belongs to `do-not-nurture`

### End an unqualified path

Use a condition followed by an Exit step on **No**. Do not create a global rule when only that one route should stop.

## Related documentation

- [Goals](/docs/workflows/workflow-goals)
- [Manual enrollment](/docs/workflows/workflow-manual-enrollment) — entry bypass does not skip these rules
- [Branching](/docs/workflows/workflow-branching)
- [Publishing and Versions](/docs/workflows/workflow-publishing)
- [Test Runs and Timelines](/docs/workflows/workflow-testing)

---

# Workflow Publishing and Versions
Source: https://lumail.io/docs/workflows/workflow-publishing.md

Workflow uses an editable draft and immutable published versions. Saving, publishing, and activating are separate actions with different effects.

## Lifecycle at a glance

| Action     | Changes draft      | Creates version | Enrolls subscribers                   | Changes existing runs                           |
| ---------- | ------------------ | --------------- | ------------------------------------- | ----------------------------------------------- |
| Save draft | Yes                | No              | No                                    | No                                              |
| Publish    | No additional edit | Yes             | Only if workflow is active            | No                                              |
| Activate   | No                 | No              | Yes, from the published version       | No                                              |
| Pause      | No                 | No              | Stops new execution until reactivated | Existing state is preserved                     |
| Archive    | No                 | No              | No                                    | Stops normal operation while preserving history |

## Drafts

The draft contains the complete current graph and workflow settings. You can save it repeatedly without changing the published version or sending anything.

The editor shows when unpublished changes exist. Resolve validation messages before publishing, but use a dry run to verify business behavior as well as graph validity.

## Publish a version

Select **Publish** when the entire draft is ready.

![Workflow toolbar with Publish and the actions menu for runs, tests, settings, and version history](/docs/workflows/publish-menu.png)

Publishing snapshots the complete definition:

- Steps and edges
- Email campaign references
- Exit mode and rules
- Success goals
- Trigger and branch configuration

The snapshot is immutable. The next publish creates a new version rather than editing the previous one.

## Activation behavior

- The first publish can activate a draft workflow.
- **Publish without activating** creates the version but leaves enrollment off.
- Publishing an active workflow keeps it active.
- Publishing a paused workflow does not silently reactivate it.
- Activation requires an existing published version.

Use publish-without-activation when the version should be reviewed by another person or prepared ahead of a launch date.

## Existing runs stay pinned

Every subscriber run stores its `workflowVersionId`. A run that enrolled on version 3 continues with version 3 even after version 4 is published.

This prevents mid-sequence changes to waits, branches, email references, goals, or exit rules. New eligible enrollments use the newly published version.

## Version history

Open **Version history** to inspect earlier snapshots and compare them with the draft or current publication.

![Workflow version history with immutable published versions](/docs/workflows/version-history.png)

Use version history to answer:

- Which graph did this run execute?
- When did an A/B weighting or goal change?
- Was the latest draft actually published?
- Which email reference belongs to an older running version?

## Safe update sequence

1. Review current workflow status and published version.
2. Edit and save the complete draft.
3. Run a dry test on every route.
4. Test-render every changed email.
5. Compare draft and published version.
6. Publish the new version.
7. Confirm status separately.
8. Inspect the first real runs and timelines.

## Pausing versus editing

Editing does not stop an active workflow. If new enrollment must stop during a sensitive change, pause the workflow explicitly, prepare and publish the new version, then reactivate it.

Do not assume pausing rewrites or deletes existing runs. Use run timelines to verify their current state.

## Agent safety

Agent and CLI draft configuration is atomic and does not publish or activate. Publishing, status changes, enrollment, deletion, and other consequential actions require a separate explicit decision.

After any agent-built draft, read the workflow back and render every internal email before publishing.

## Related documentation

- [Getting Started](/docs/workflows/workflow-getting-started)
- [A/B Testing](/docs/workflows/workflow-ab-testing)
- [Test Runs and Timelines](/docs/workflows/workflow-testing)
- [Email Step](/docs/workflows/workflow-email-step)

---

# Workflow Test Runs and Results
Source: https://lumail.io/docs/workflows/workflow-testing.md

Workflow provides two levels of proof: a dry run verifies routing without side effects, and real-run dashboards verify production behavior after publication.

## Start a dry run

Open the workflow editor, select **Test run**, and choose one subscriber.

![Test-run panel for selecting one subscriber and opening the dry-run timeline](/docs/workflows/test-run.png)

A dry run:

- Uses an isolated run marked **Dry run**
- Evaluates trigger-independent graph routing for the selected subscriber
- Evaluates conditions, splits, waits, goals, and exits
- Records email and action steps as skipped
- Does not send email, mutate tags or fields, unsubscribe, call webhooks, or enroll another workflow
- Does not count in production active-run or A/B metrics

Use subscribers whose tags, fields, and revenue represent each important path.

## Read the timeline

After starting the test, Lumail opens the run timeline.

![Workflow run timeline with status, version, current step, and trace events](/docs/workflows/run-timeline.png)

The left card identifies the subscriber, run number, workflow version, status, current step, and next action. The trace records:

- Step started and completed
- Condition and split decisions
- Wait scheduling and timeout behavior
- Dry-run email and action skips
- Goal reached
- Exit reason
- Failure details

Click a step in the trace to inspect its events in execution order.

## What a dry run cannot prove

A dry run does not prove:

- Email rendering or inbox delivery
- Real webhook responses
- Tag, field, unsubscribe, or cross-workflow side effects
- External success-goal events arriving later
- Queue latency or organization sending limits

Test-render every email separately. To walk the real send path without SES, enroll a [fixture subscriber](/docs/workflows/test-workflow) such as `qa@test.com`. Then monitor the first real runs after activation.

## Workflow dashboard

The dashboard combines the published graph with current production data:

- Total, active, waiting, completed, canceled, and failed runs
- Current subscribers by step
- Recent enrollment trend
- Delivered and opened email totals
- Recent runs
- A/B assignments and success rates

![Workflow dashboard with graph, current subscribers, and result summaries](/docs/workflows/workflow-dashboard.png)

Dashboard counts should always be interpreted with the published version and selected time window in mind.

## A/B results

The **A/B success results** card counts one assignment per run and split step. Success means that run recorded a top-level success goal.

See [A/B Testing](/docs/workflows/workflow-ab-testing) before comparing rates.

## Run statuses

| Status    | Meaning                                                                     |
| --------- | --------------------------------------------------------------------------- |
| Active    | Executing or ready for its next step                                        |
| Waiting   | Waiting for time, an event, subscriber state, or sending access             |
| Completed | Reached the end, a goal, or an explicit Exit step                           |
| Canceled  | Stopped by a global exit rule, group conflict, removal, or lifecycle action |
| Failed    | Stopped by an unrecoverable error                                           |

Use the timeline event—not status alone—to distinguish normal completion, goal conversion, and explicit exit.

## Production verification checklist

1. Confirm the intended version is published and status is active.
2. Inspect the first real enrollment.
3. Verify its version, first route decision, and next action time.
4. Confirm workflow email render, sender, and unsubscribe link.
5. Confirm each external side effect by read-back.
6. Inspect goal or exit events when expected.
7. Check failures and runs waiting longer than their configured timeout.
8. For A/B tests, confirm both assignment counts increase before interpreting rates.

## Troubleshooting

| Symptom                    | Check                                                                                     |
| -------------------------- | ----------------------------------------------------------------------------------------- |
| No new runs                | Workflow status, published version, trigger filters, repeat cooldown, and group conflicts |
| Run stuck waiting          | Next action time, timeout route, audience-change flag, and sending suspension             |
| A/B success is always zero | A top-level success goal exists and can match real events                                 |
| Unexpected exit            | Matched rule and version in the timeline                                                  |
| Dry run skipped an email   | Expected behavior; render or send a dedicated email test instead                          |
| Real run uses old logic    | It enrolled on an older immutable version                                                 |

## Related documentation

- [Test Workflow](/docs/workflows/test-workflow)
- [Publishing and Versions](/docs/workflows/workflow-publishing)
- [A/B Testing](/docs/workflows/workflow-ab-testing)
- [Goals](/docs/workflows/workflow-goals)
- [Exit Rules](/docs/workflows/workflow-exit-rules)

---

# Test Workflow
Source: https://lumail.io/docs/workflows/test-workflow.md

Use fixture recipients when you want the **full send path** — render, queue, Emails list, workflow run — without SES leaving Lumail. That keeps complaints and bounces off your sending domain.

A [dry run](/docs/workflows/workflow-testing) is different: it skips email steps entirely. Fixture sends go through the real dispatcher and are recorded as sent.

## Fixture addresses

Any of these is auto-faked. The email is accepted, stored, and marked sent. SES is never called.

| Pattern | Example |
| ------- | ------- |
| `example.com`, `example.net`, `example.org`, including subdomains | `ada@example.com`, `ada@mail.example.org` |
| `test.com`, `test.net`, `test.org`, `test.dev`, including subdomains | `ada@test.com`, `ada@qa.test.dev` |
| Special-use TLDs `.test`, `.example`, `.invalid`, `.localhost` | `ada@signup.test`, `ada@nowhere.invalid` |
| Local-part starts with `playwright-test-` (any domain) | `playwright-test-ada@gmail.com` |

Display names are stripped first (`Ada <ada@test.com>` is still a fixture).

Do **not** use a real mailbox (`ada@gmail.com`, your own domain). Those still go to SES when sending is enabled and can bounce or complain.

`example.edu` and lookalikes such as `contest.com` or `mytest.com` are real domains and still send.

## Workflow

1. Create a subscriber with a fixture address, for example `qa+welcome@test.com`.
2. Give them the tags, fields, and revenue the graph actually reads.
3. Publish the workflow, then enroll that subscriber (trigger or [manual enrollment](/docs/workflows/workflow-manual-enrollment)).
4. Open the run timeline. Email steps execute instead of skipping.
5. Open **Emails** — the row is there, status is sent, and nothing hit a real inbox.

Repeat with one fixture subscriber per important path (welcome, branch A, exit, goal).

## Campaigns, API, SMTP

The same gate is shared:

- Campaign **Send test email**
- Broadcast and workflow campaign sends
- Transactional HTTP and SDK
- SMTP

Send to `preview@example.com` or `preview@test.dev` from any of those surfaces.

## What you get / what you do not

| Happens | Does not happen |
| ------- | --------------- |
| Render, personalization, unsubscribe link | SES API call |
| Queue, journal, Emails list | Inbox delivery |
| Workflow run continues past the email step | Bounce or complaint events |
| Recorded as sent | Reputation impact on your domain |

You still need one real send to yourself (or a mailbox you control) if you want to inspect the inbox, spam folder, or rendering in Gmail / Apple Mail.

## Related

- [Test-mode recipients](/docs/api-reference/test-mode) — exact match rules
- [Workflow Test Runs and Results](/docs/workflows/workflow-testing) — dry runs with no email side effects
- [Email Step](/docs/workflows/workflow-email-step)
- [Send Transactional Email](/docs/api-reference/api-emails-send)

---

# Wait Step
Source: https://lumail.io/docs/workflows/workflow-wait-step.md

Pause your workflow before proceeding to the next step.

## Wait Types

### Duration

Wait for a specific amount of time (minutes, hours, or days).

### Time

Wait until a specific time of day. Uses your organization's timezone.

If less than 10 minutes until target time, waits until next day to prevent spam.

### Day of Week

Wait until a specific day and time (e.g., Monday at 9:00 AM).

If target day/time passed this week, waits until next week.

## Timezone

Configure your timezone in **Settings > Email Settings**. Default is `Europe/Paris`.

## Workflow Wait Until

`WAIT_UNTIL` pauses until either subscriber filters match or a subscriber event arrives. It must have both `MATCHED` and `TIMEOUT` paths.

Filter mode:

```json
{
  "id": "wait-until-customer",
  "type": "WAIT_UNTIL",
  "config": {
    "mode": "FILTER",
    "filters": [
      {
        "type": "TAG",
        "field": "tags",
        "operator": "BELONGS_TO_ANY",
        "tagIds": ["tag-customer"]
      }
    ],
    "timeout": { "duration": 7, "unit": "days" }
  }
}
```

Event mode accepts the complete Workflow event list and strict `tagIds`, `product`, or `campaignId` event filters. A timeout is optional in storage, but the graph still requires a `TIMEOUT` edge so every route is explicit.

While waiting, the run uses status `WAITING`, stores `nextActionAt` for timeouts, and can set `waitingForAudienceChange` so relevant subscriber updates wake evaluation.

## Incremental schedules

Wait values are relative to the previous step. For J0/J1/J3/J5, configure waits of 1 day, 2 days, and 2 days—not absolute waits of 1, 3, and 5 days.

## Related Documentation

- [Workflows Overview](/docs/workflows) - Learn about workflow automation
- [Email Step](/docs/workflows/workflow-email-step) - Send emails after waiting
- [Action Step](/docs/workflows/workflow-action-step) - Perform actions after waiting

---

# Email Step
Source: https://lumail.io/docs/workflows/workflow-email-step.md

Send a personalized email to the current subscriber. Workflow gives every `EMAIL` step a dedicated internal campaign owned by that exact workflow and step.

## Workflow ownership

- Internal emails use campaign `type: WORKFLOW` and do not appear as broadcast campaigns.
- One internal campaign belongs to one workflow step.
- A broadcast campaign or another step's email is duplicated instead of linked directly.
- Deleting or replacing a step cleans up internal campaigns that are no longer referenced by any preserved version.
- Published versions preserve the email references used by existing runs.

When an agent creates or changes an email, it provides complete inline content to `configure_workflow_draft`. Lumail resolves the owned `campaignId` in the same transaction.

```json
{
  "id": "email-welcome",
  "type": "EMAIL",
  "name": "Welcome",
  "email": {
    "subject": "Welcome aboard",
    "preview": "Your first steps",
    "senderId": "sender-id",
    "replyTo": "support@example.com",
    "content": {
      "type": "doc",
      "content": [
        {
          "type": "paragraph",
          "content": [{ "type": "text", "text": "Welcome!" }]
        }
      ]
    }
  }
}
```

Never use `create_campaign` for a Workflow email. After configuration, read and render every ID returned in `configuredEmails`.

## Behavior

- Fetches fresh subscriber data before sending
- Skips if subscriber unsubscribed (workflow continues)
- Fails validation when a required owned campaign reference is missing
- Fails if email limit reached

## Duplicate Prevention

Each subscriber receives each campaign only once by default.

With **Allow Repeats** enabled, the subscriber can start another run after `repeatCooldownMinutes`. Delivery deduplication remains scoped to the run and step so retries do not duplicate accepted sends.

## Sending Queue

Workflow emails are **queued exactly like campaigns**. Each email step is backed by a
campaign behind the scenes, so its emails:

- Use the **bulk lane**, the same one regular campaigns use. Only transactional emails
  (login codes, notifications) and campaign test emails get instant priority.
- Join your organization's **single sending queue**, in **first in, first out** order.

This means a workflow email queued while a large campaign is already sending waits for that
campaign to finish draining before it goes out. Workflow emails are not prioritized over
campaigns, and campaigns are not prioritized over workflow emails.

Two consequences worth planning around:

- **Avoid launching a large campaign during peak workflow activity** if the workflow email
  is time-critical (welcome email, purchase confirmation sequence).
- **Sending is never lost when the queue is busy** — emails wait their turn, and each
  recipient is tracked individually so a retry never causes a duplicate.

If sending is suspended (monthly quota reached or plan suspension), the workflow run is
**paused** rather than failed, and resumes automatically once sending is active again.

## Verification

For every internal workflow email:

1. Read it with `get_campaign`.
2. Compare sender, subject, preview, reply-to, and TipTap content.
3. Render it with `render_campaign`.
4. Require non-empty HTML and text plus `hasUnsubscribeLink: true`.
5. Confirm `list_campaigns` does not expose it as a broadcast.

See [Email Sending Queue](/docs/features/email-sending-queue) for the full picture,
including how capacity is shared between organizations and plans.

## Related Documentation

- [Email Sending Queue](/docs/features/email-sending-queue) - How campaigns and workflow emails share the queue

- [Send Email API](/docs/api-reference/api-emails-send) - Send emails outside workflows
- [Variables](/docs/features/variables) - Personalize email content
- [Tag Action Links](/docs/features/tag-action-links) - Add one-click tag actions in email links
- [Content Deliverability](/docs/features/content-deliverability) - Optimize email content for inbox placement
- [Subscriber Events](/docs/features/subscriber-events) - View email delivery events

---

# Action Step
Source: https://lumail.io/docs/workflows/workflow-action-step.md

Perform subscriber management operations.

## Action Types

### Add Tags

Apply tags to the subscriber. Can trigger other workflows using "Tag Added" trigger.

If **Add Tags** is the final action in a workflow, Lumail completes the current run before evaluating workflows triggered by those tags. This makes a chain such as `Workflow A → add tag-b → Workflow B` compatible with a **First Wins** workflow group.

Keep the handoff tag in the final action. A tag added earlier in the workflow is evaluated immediately and can be blocked by First Wins while the current workflow is still active.

### Remove Tags

Remove tags from the subscriber.

### Unsubscribe

Change subscriber status to unsubscribed. They will no longer receive emails.

### Add to Workflow

Add the subscriber to another eligible Workflow. The action can optionally target a starting step with `workflowStepId`. The target must be active and published, and its repeat, group, subscriber-status, and sending-access rules still apply. The target's trigger events and trigger audience filters are not re-checked — the same bypass as [manual enrollment](/docs/workflows/workflow-manual-enrollment). Exit rules on the target still apply.

### Set Field

Set one subscriber field using `fieldId` and a string `value`. Field changes can wake filter-based `WAIT_UNTIL` steps and success goals.

### Webhook

Call an HTTPS endpoint with an optional secret and arbitrary JSON payload. Agent reads replace `config.secret` with `[REDACTED]`. That placeholder only copies a **previously stored real** secret. First configure or a new webhook step must write the real secret or omit it; otherwise the write is rejected. See [Webhook Step](/docs/workflows/workflow-webhook-step).

## Workflow configuration

```json
{
  "id": "action-customer",
  "type": "ACTION",
  "config": {
    "actionType": "SET_FIELD",
    "fieldId": "field-plan",
    "value": "customer"
  }
}
```

Dry runs record `ACTION_SKIPPED_DRY_RUN` and do not execute tag, field, enrollment, unsubscribe, or webhook side effects.

## Related Documentation

- [Tag Action Links](/docs/features/tag-action-links) - Let subscribers add or remove tags by clicking email links
- [Tags API](/docs/api-reference/api-tags-get) - Manage tags programmatically
- [Add Tags to Subscriber](/docs/api-reference/api-subscriber-tags-post) - Add tags via API
- [Remove Tags from Subscriber](/docs/api-reference/api-subscriber-tags-delete) - Remove tags via API
- [Subscriber Events](/docs/features/subscriber-events) - View tag change history

---

# Webhook Step
Source: https://lumail.io/docs/workflows/workflow-webhook-step.md

Send HTTP POST requests to external services.

In Workflow, webhooks are configured as an `ACTION` step with `actionType: WEBHOOK` rather than a separate step type.

## Configuration

- **Webhook URL**: HTTP(S) endpoint to call
- **Webhook Secret**: Optional secret for authentication
- **Payload**: Optional JSON object nested under `payload` on the request body. It is never merged onto the top-level fields.
- **Body format**: `json` (default) or `form`. Use `form` for endpoints that expect `email=...` form data, such as a Deadline Funnel custom webhook.

```json
{
  "id": "notify-crm",
  "type": "ACTION",
  "config": {
    "actionType": "WEBHOOK",
    "url": "https://example.com/webhooks/lumail",
    "secret": "optional-secret",
    "payload": { "source": "welcome-workflow" },
    "bodyFormat": "json"
  }
}
```

`get_workflow` / `get_workflow_draft` replace `config.secret` with the literal `[REDACTED]`. That is a **read placeholder**, not a keep-token.

- **First configure**, or any **new** webhook step: write the real secret or omit it. Never paste a redacted `get_workflow` payload as the first draft.
- **Later graph updates** may send `"secret": "[REDACTED]"` only if draft already stores a real secret. `restoreRedactedWorkflowSecrets` then copies the stored value.
- If the stored value is missing, empty, or already `[REDACTED]`, sending `[REDACTED]` is **rejected**. The placeholder is never stored.
- After configure/publish, `get_workflow` still shows `[REDACTED]`. That does **not** mean the stored secret is valid.

## Request

`POST` with `content-type: application/json`. When a secret is set, the same value is sent as `x-lumail-signature` and as `body.secret`.

```json
{
  "subscriberId": "sub_123",
  "subscriberEmail": "user@example.com",
  "organizationId": "org_xyz",
  "workflowId": "wf_789",
  "stepId": "step_456",
  "secret": "your-secret",
  "payload": { "source": "welcome-workflow" }
}
```

`secret` is omitted when the step has no secret. `payload` is always an object (`{}` when unset). Do not flatten `payload` onto the top level.

`get_workflow` always redacts `config.secret`. Do not treat that as proof the stored secret is real. See the rules above.

### Form body

With `"bodyFormat": "form"`, the request is sent as `content-type: application/x-www-form-urlencoded`:

```
email=user%40example.com&subscriberId=sub_123&organizationId=org_xyz&workflowId=wf_789&stepId=step_456&secret=your-secret&payload%5Bsource%5D=welcome-workflow
```

The subscriber email is sent as `email`. Each `payload` key becomes `payload[key]`: strings are sent as-is, and other values are JSON-encoded. A payload key never overrides a top-level field. `secret` is omitted when unset and is still sent as `x-lumail-signature`.

### Deadline Funnel

1. In your Deadline Funnel campaign, set the email platform to **Other** and copy the custom webhook URL from the **Emails** tab.
2. Add a Webhook action where the deadline should start, paste the URL, and set **Body format** to **Form**.
3. In your Lumail emails, replace the Deadline Funnel email placeholder with `{{email}}` in email links (`https://dfl0.us/s/abc123?em={{email}}`) and in the email timer image URL. Copy a fresh timer URL for each email.

## Response

- **Success**: HTTP status < 400, workflow continues
- **Timeout**: Lumail waits up to 30 seconds for a response. A timed-out request is retried, so the same call can reach your endpoint more than once: make it idempotent on `subscriberId` + `workflowId` + `stepId`
- **Failure**: HTTP 4xx (except 408/429) fails the run immediately and shows the status plus response body. 5xx, 408, 429, and timeouts retry then fail. The FAILED run event stores HTTP status, a truncated response body, and the URL host
- **Dry run**: Records a skipped-action timeline event and makes no HTTP request

## Related Documentation

- [Dynamic Promo Codes Tutorial](/docs/tutorials/dynamic-promo-codes) - Use webhooks for personalized coupons
- [Update Subscriber API](/docs/api-reference/api-subscribers-patch) - Update subscribers from webhook responses
- [API Tokens](/docs/api-reference/api-tokens) - Authenticate webhook callbacks
- [Subscriber Events](/docs/features/subscriber-events) - View webhook execution events

---

# Workflow Groups
Source: https://lumail.io/docs/workflows/workflow-groups.md

When subscribers match triggers for several workflows, they can end up receiving too many emails at the same time. Workflow groups solve this by making workflows **mutually exclusive** - a subscriber can only be active in one workflow per group.

## How It Works

1. You create a **group** and choose a conflict resolution strategy.
2. You assign workflows to that group via **Workflow Settings**.
3. Whenever a subscriber is about to be enrolled in a workflow that belongs to a group, the system checks if the subscriber is already active in another workflow from the same group.
4. If there is a conflict, the chosen strategy decides what happens.

Workflows without a group are unaffected and work independently.

## Conflict Resolution Strategies

Each group has one strategy that applies to every conflict within that group.

### Latest Wins (CANCEL_EXISTING)

The new workflow takes priority. All active workflows in the group are canceled for that subscriber, and the new workflow starts normally.

**Use when:** the most recent workflow is always the most relevant (e.g. re-onboarding after a plan change).

### First Wins (SKIP_NEW)

The existing workflow keeps running. A conflicting enrollment from an unrelated trigger is skipped.

When the existing workflow's **final action adds a tag that triggers the next workflow in the same group**, Lumail waits for the current workflow to complete and then evaluates the next enrollment. This lets you chain mutually exclusive funnel stages without running two stages at once.

**Use when:** you want to protect an in-progress sequence from being interrupted (e.g. a 7-day welcome series should finish before anything else starts).

### Send First Email Only (RUN_FIRST_STEP)

If the subscriber is already enrolled in another workflow from the group, they are **not** enrolled in the new one - but the first email step is still sent as a standalone message. This way the subscriber gets the initial email without going through the full sequence.

**Use when:** you want to acknowledge the trigger without committing to a full workflow (e.g. send a quick "thanks for signing up" even though a welcome series is already running).

## When Conflicts Are Checked

The mutual exclusion check runs in **all enrollment paths**:

- Tag-based triggers (subscriber gets a tag that matches a workflow trigger)
- Hatchet subscriber events
- "Add to Workflow" action steps inside other workflows
- Manual enrollment via the AI assistant
- Bulk "Add to Workflow" from the subscribers list
- API-driven enrollment

## Important Behavior

- **Active or waiting runs conflict.** Completed, canceled, and failed runs do not create a permanent lock.
- **A terminal Add Tags action is evaluated after completion.** The tag is added immediately, but workflows triggered by that tag are evaluated once the emitting run is complete.
- **A non-terminal Add Tags action is evaluated immediately.** If another workflow in the group is still active, First Wins skips that enrollment as usual.
- **Groups are per-organization.** Each organization manages its own groups independently.
- **Group names are unique** within an organization. Workflow creation and updates reject case-insensitive duplicates.
- **Renaming preserves membership.** Existing runs keep their status, while future enrollments use the updated group.
- **Removing a workflow from a group** (setting group to "No group") has no effect on subscribers already enrolled - it only affects future enrollments.
- **Deleting a group** automatically removes the group assignment from all workflows (they become ungrouped). No active workflows are interrupted.

## Creating a Group

1. Open any workflow and open **Workflow settings > General**.
2. Open the **Workflow group** picker, type a name, and choose **Create “name”** (or **New group**).
3. Pick a conflict resolution strategy. The dialog previews what happens when a subscriber already in the group triggers this workflow.
4. Click **Create group**, then **Save settings**. The new group is selected for this workflow.

Once a group is selected, the settings panel lists the other workflows it is mutually exclusive with. Use the **⋯** menu on the group to rename it, change its strategy, or delete it. You can assign the same group to other workflows from their own settings.

## Managing Workflow groups with an agent or CLI

Workflow group tools are available for every organization.

| Tool                    | Purpose                                                                   |
| ----------------------- | ------------------------------------------------------------------------- |
| `list_workflow_groups`  | Discover group IDs, names, strategies, counts, and concurrency timestamps |
| `get_workflow_group`    | Inspect assigned workflows, active-run counts, and conflict strategy      |
| `create_workflow_group` | Create a group; retries are idempotent when name and strategy match       |
| `update_workflow_group` | Rename or change strategy after a fresh read                              |
| `set_workflow_group`    | Assign or detach a workflow without replacing its graph                   |
| `delete_workflow_group` | Permanently delete a group through the confirmation flow                  |

Safe sequence:

1. Call `list_workflow_groups`; never invent a `groupId`.
2. Call `get_workflow_group` and preserve its exact `updatedAt` before updating or deleting.
3. Call `get_workflow` and preserve its exact `updatedAt` before assigning or detaching.
4. Create, update, or assign the group.
5. Read both the group and workflow back to verify the result.

```bash
lumail tools run list_workflow_groups \
  --account <org> \
  --params '{}'

lumail tools run create_workflow_group \
  --account <org> \
  --params '{"name":"Onboarding","conflictStrategy":"SKIP_NEW"}'

lumail tools run set_workflow_group \
  --account <org> \
  --params '{"workflowId":"workflow-id","expectedUpdatedAt":"2026-07-27T10:00:00.000Z","groupId":"group-id"}'
```

`set_workflow_group` changes only the group assignment. It does not replace the graph, publish, activate, enroll, or send.

Group deletion is destructive and confirmation-gated. The tool requires the expected name and timestamp, refuses to delete a group with assigned workflows by default, and requires `unassignWorkflows: true` before using the database's `SET NULL` behavior. Existing runs are never canceled by group deletion.

## Example

You have three workflows:

| Workflow       | Group      | Status |
| -------------- | ---------- | ------ |
| Welcome Series | Onboarding | Active |
| Re-engagement  | Onboarding | Active |
| Product Tips   | _(none)_   | Active |

The Onboarding group uses **First Wins**.

- A subscriber triggers Welcome Series and starts receiving emails.
- While still active in Welcome Series, the same subscriber triggers Re-engagement. Because of First Wins, the enrollment is **skipped** - Welcome Series continues uninterrupted.
- The same subscriber triggers Product Tips. Since Product Tips has **no group**, it starts normally - groups only affect workflows within the same group.
- Once Welcome Series completes, the subscriber triggers Re-engagement again. This time there is no active conflict, so Re-engagement starts.

## Chaining a funnel with tags

Use a final **Add Tags** action when one stage should hand the subscriber to the next stage.

For example, your Grand Tour contains four workflows in the `Grand Tour` group, using **First Wins**:

| Workflow | Trigger            | Final action        |
| -------- | ------------------ | ------------------- |
| Tour A   | Tag `tour-a` added | Add tag `tour-b`    |
| Tour B   | Tag `tour-b` added | Add tag `tour-c`    |
| Tour C   | Tag `tour-c` added | Add tag `tour-d`    |
| Tour D   | Tag `tour-d` added | Add tag `tour-done` |

When a subscriber receives `tour-a`:

1. Tour A starts and remains the only active workflow in the group.
2. Its final action adds `tour-b`.
3. Lumail marks Tour A as completed.
4. Lumail evaluates the `tour-b` trigger and enrolls the subscriber in Tour B.
5. The same handoff repeats until Tour D completes.

At no point are Tour A and Tour B active together. The tag remains visible in the subscriber timeline when it is added; only the next enrollment waits for completion.

If **Add tag `tour-b`** appears before the final step, First Wins still evaluates it immediately and skips Tour B because Tour A is active. Move the handoff tag to the final action when you want sequential chaining.

## Related Documentation

- [Creating Workflows](/docs/workflows) - Overview of workflow setup
- [Manual enrollment](/docs/workflows/workflow-manual-enrollment) - Bulk add from the subscribers list still respects group rules
- [Action Step](/docs/workflows/workflow-action-step) - "Add to Workflow" action respects group rules
- [Create Subscriber](/docs/api-reference/api-subscribers-post) - API enrollment and `triggerWorkflows`

---

# Creating Workflows
Source: https://lumail.io/docs/workflows.md

Workflows in Lumail allow you to create automated sequences that trigger based on subscriber actions or other conditions.

## One graph-based workflow engine

Every workflow uses a validated graph of steps and edges, editable drafts,
immutable published versions, detailed run timelines, goals, exits, and mutual
exclusion groups. The same canonical workflow tools are available in the app,
MCP, CLI, SDK, and Tools API.

Start with the [guided setup](/docs/workflows/workflow-getting-started), then use the focused guides for [triggers](/docs/workflows/workflow-triggers), [manual enrollment](/docs/workflows/workflow-manual-enrollment), [branching](/docs/workflows/workflow-branching), [A/B tests](/docs/workflows/workflow-ab-testing), [goals](/docs/workflows/workflow-goals), [exit rules](/docs/workflows/workflow-exit-rules), [publishing](/docs/workflows/workflow-publishing), [run analysis](/docs/workflows/workflow-testing), and [fixture sends](/docs/workflows/test-workflow).

## What are Workflows?

Workflows are a series of steps that execute automatically based on triggers. You can use workflows to:

- Send welcome emails when subscribers join your list
- Create multi-step nurture sequences
- Segment subscribers based on their behavior
- Deliver content on a schedule

## Creating Your First Workflow

To create a workflow, navigate to the Workflows section in your organization dashboard:

1. Click on **Workflows** in the left sidebar
2. Click the **Create Workflow** button
3. Give your workflow a descriptive name
4. Add your first trigger

## Workflow Components

### Triggers

Triggers are events that start your workflow. Common triggers include:

- **Subscriber Added to Tag**: Activates when a tag is applied to a subscriber
- **Form Submission**: Activates when someone completes a capture form
- **Date Based**: Activates at a specific date/time
- **[Manual](/docs/workflows/workflow-manual-enrollment)**: Filter subscribers, select all, and start a published workflow without a trigger

### Steps

After setting a trigger, you can add steps to your workflow:

- **[Wait Step](/docs/workflows/workflow-wait-step)**: Pause for a specified time before continuing. Supports duration, specific time, or day of week.
- **[Email Step](/docs/workflows/workflow-email-step)**: Send an email to the subscriber with duplicate prevention and limit enforcement.
- **[Action Step](/docs/workflows/workflow-action-step)**: Add tags, remove tags, unsubscribe, or add to another workflow.
- **[Webhook Step](/docs/workflows/workflow-webhook-step)**: Send HTTP requests to external services for integrations.

### Example Workflow

Here's an example of a simple welcome sequence:

1. **Trigger**: Subscriber Added to Tag "newsletter"
2. **Step 1**: Email - Welcome message with introduction
3. **Step 2**: Wait - 2 days
4. **Step 3**: Email - Educational content
5. **Step 4**: Wait - 3 days
6. **Step 5**: Email - Promotional offer

## Best Practices

- **Start Small**: Begin with simple workflows before building complex ones
- **Test Thoroughly**: Send test emails to yourself before activating
- **Monitor Performance**: Check open rates and adjust timing as needed
- **Clear Naming**: Use descriptive names for workflows and steps
- **Exit Conditions**: Consider how subscribers might exit the workflow

## Advanced Features

- **[Workflow Groups](/docs/workflows/workflow-groups)**: Prevent subscribers from being enrolled in multiple workflows at once using mutual exclusion
- **[A/B Testing](/docs/workflows/workflow-ab-testing)**: Compare separate paths against the same conversion goal
- **[Segmentation](/docs/workflows/workflow-branching)**: Route subscribers with conditions, event waits, and weighted splits
- **[Goals](/docs/workflows/workflow-goals)**: Track graph milestones or conversions that happen outside the current step
- **[Exit Rules](/docs/workflows/workflow-exit-rules)**: Stop subscribers before later steps when their state changes
- **Integration**: Connect with other platforms via webhooks
- **Analytics**: Track conversion through each step

Workflows are a powerful way to automate your email marketing and deliver the right message at the right time.

## Related Documentation

- [Variables](/docs/features/variables) - Personalize workflow emails
- [Tag Action Links](/docs/features/tag-action-links) - Let subscribers add or remove tags from an email, including tag-triggered workflow entry
- [Subscriber Events](/docs/features/subscriber-events) - View workflow execution history
- [Tags API](/docs/api-reference/api-tags-get) - Manage trigger tags programmatically
- [Content Deliverability](/docs/features/content-deliverability) - Optimize workflow emails for inbox placement
- [Create Subscriber](/docs/api-reference/api-subscribers-post) - Control workflow triggering with `triggerWorkflows`

---

# Email Domains
Source: https://lumail.io/docs/domains/email-domains.md

Email domains allow you to send emails from your own domain (e.g., `newsletter@yourdomain.com`) instead of a shared domain. This improves deliverability, builds trust, and strengthens your brand.

## Why Use a Custom Email Domain?

- **Better Deliverability**: Emails from your own domain are less likely to be marked as spam
- **Brand Recognition**: Recipients see your domain, not a generic one
- **Professional Appearance**: Build trust with a consistent brand experience
- **Domain Reputation**: Build your own sender reputation over time

## Adding an Email Domain

1. Go to **Domains** in your organization settings
2. Click **Add Email Domain**
3. Enter your domain name (e.g., `yourdomain.com`)
4. Click **Create**

Each plan includes a fixed number of sending domains: **1 on Free, 10 on Creator, 50 on Pro, 1,000 on Business**. Shared test domains do not count. When you hit the cap, **Upgrade** opens the billing plan page.

## DNS Configuration

After adding your domain, you'll need to configure DNS records at your domain registrar. Lumail.io requires the following records:

### SPF Record

SPF (Sender Policy Framework) authorizes Lumail.io to send emails on behalf of your domain.

| Type | Host | Value                               |
| ---- | ---- | ----------------------------------- |
| TXT  | @    | `v=spf1 include:amazonses.com ~all` |

### DKIM Records

DKIM (DomainKeys Identified Mail) signs your emails to verify they haven't been tampered with.

You'll receive 3 CNAME records to add:

| Type  | Host                | Value                         |
| ----- | ------------------- | ----------------------------- |
| CNAME | `[key1]._domainkey` | `[value1].dkim.amazonses.com` |
| CNAME | `[key2]._domainkey` | `[value2].dkim.amazonses.com` |
| CNAME | `[key3]._domainkey` | `[value3].dkim.amazonses.com` |

### DMARC Record (Recommended)

DMARC tells receiving servers what to do with emails that fail SPF or DKIM checks. Start with `p=none` (monitor only), then move to `p=quarantine` or `p=reject`. Lumail warns while the policy stays on `p=none`.

| Type | Host     | Value                                               |
| ---- | -------- | --------------------------------------------------- |
| TXT  | `_dmarc` | `v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com` |

If the sending domain is a subdomain and `_dmarc` is only on the root, edit the **root** record. See [How to add DMARC](/docs/deliverability/how-to-add-dmarc) and [How to strengthen DMARC](/docs/deliverability/how-to-strengthen-dmarc).

## Verification

After adding DNS records:

1. Return to the domain detail page
2. Click **Check Verification Status**
3. Wait for DNS propagation (can take up to 48 hours)

Once verified, the domain status will change from **Pending** to **Verified**.

## Fallback Domains

Fallback domains keep campaign and workflow emails moving when the primary sending domain has a domain-level SES issue, such as an invalid MAIL FROM setup or a rejected sender identity.

On the **Domains** page, use the **Fallback** column to assign a verified email domain to a priority:

- **Priority 1** is tried first
- **Priority 2** is tried after Priority 1
- **Priority 3** is tried last
- **Off** means the domain is not used as a fallback

When a primary domain fails, Lumail.io tries fallback domains in priority order. If a fallback succeeds, Lumail.io caches that fallback choice for 10 minutes for the same organization and primary domain. During that window, new emails skip the failing primary domain and use the cached fallback directly.

The fallback keeps the same display name and local part, but replaces the domain. For example, `Melvyn <hello@primary.com>` can be retried as `Melvyn <hello@fallback.com>`.

Admins receive a notification when a fallback is used. If no fallback succeeds, Lumail.io sends an urgent failure notification and stops retrying permanent SES failures for that recipient.

Fallback domains only help with domain or identity failures. They do not fix account-level quota limits, throttling, or a missing SES configuration set.

## Troubleshooting

### Domain stuck on "Pending"

- **DNS Propagation**: DNS changes can take up to 48 hours to propagate globally
- **Check Records**: Use a tool like [MXToolbox](https://mxtoolbox.com/) to verify your DNS records
- **TTL Settings**: Lower TTL values speed up propagation

### Emails going to spam

- Ensure all DNS records (SPF, DKIM, DMARC) are properly configured
- Start with a small sending volume and gradually increase
- Maintain good list hygiene (remove bounces and unsubscribes)

## Related Documentation

- [Send Email API](/docs/api-reference/api-emails-send) - Requires verified domain for `from` address
- [Deliverability Score](/docs/features/deliverability-score) - Monitor sender reputation
- [Content Deliverability](/docs/features/content-deliverability) - Optimize emails for inbox placement

---

# Web Domains
Source: https://lumail.io/docs/domains/web-domains.md

Web domains allow you to serve your capture pages on a custom domain (e.g., `pages.yourdomain.com` or `subscribe.yourdomain.com`) instead of the default Lumail.io subdomain.

## Why Use a Custom Web Domain?

- **Brand Consistency**: Your capture pages appear on your own domain
- **Trust**: Visitors see your domain in the URL bar
- **SEO Benefits**: Build domain authority on your own domain
- **Professional Appearance**: Remove third-party branding from URLs

## Adding a Web Domain

1. Go to **Domains** in your organization settings
2. Click **Add Web Domain**
3. Enter your subdomain (e.g., `pages.yourdomain.com`)
4. Click **Create**

Each plan includes a fixed number of web domains: **1 on Free and Creator, 3 on Pro, 5 on Business**. Capture pages still work on the default Lumail URL if you hit the cap. When you do, **Upgrade** opens the billing plan page.

## DNS Configuration

After adding your domain, configure a CNAME record at your domain registrar:

| Type  | Host                        | Value             | TTL  |
| ----- | --------------------------- | ----------------- | ---- |
| CNAME | `pages` (or your subdomain) | `cname.lumail.io` | 3600 |

That is the only record. Do not point the domain at a raw VPS IP.

## Verification Process

1. Add the CNAME at your DNS provider
2. Return to Lumail.io and click **Check DNS Records Status**
3. SSL is issued automatically once the CNAME is live

## Using Your Web Domain

Once verified, your capture pages are automatically available at your custom domain:

- **Before**: `lumail.io/p/your-org/page-slug`
- **After**: `pages.yourdomain.com/page-slug`

All existing capture page links continue to work. The custom domain is an additional way to access your pages.

## Apex Domains vs Subdomains

### Subdomains (Recommended)

Use a subdomain like `pages.yourdomain.com` or `subscribe.yourdomain.com`:

| Type  | Host    | Value             |
| ----- | ------- | ----------------- |
| CNAME | `pages` | `cname.lumail.io` |

### Apex Domains

For apex domains (e.g., `yourdomain.com` without a subdomain), create a CNAME or ALIAS for `@` to `cname.lumail.io`. This requires CNAME flattening (Cloudflare DNS) or an ALIAS/ANAME record. A plain A record will not work.

Subdomains are recommended.

## Troubleshooting

### Domain stuck on "Pending"

- **Check CNAME**: It must be `cname.lumail.io`
- **DNS Propagation**: Wait up to 48 hours for DNS changes to propagate
- **Use dig**: Run `dig pages.yourdomain.com` to check if the record exists

### SSL Certificate Issues

Lumail creates the Cloudflare custom hostname with HTTP DCV and routes origin TLS through `cname.lumail.io` so Cloudflare does not 526 on the customer hostname. Click **Check Verification Status** if you still see Error 526 — that retriggers the certificate and origin SNI.

- The CNAME must be `cname.lumail.io`
- If the domain is already on Cloudflare, keep the CNAME DNS-only (grey cloud) unless you know you want orange-cloud / O2O
- Confirm there is no conflicting A record

## Related Documentation

- [Create Subscriber API](/docs/api-reference/api-subscribers-post) - Capture page form integration

---

# Tracking domains
Source: https://lumail.io/docs/domains/click-domains.md

Tracked clicks, opens, the online version, and unsubscribe links need a host that is not your From domain. A **custom tracking domain** is the durable fix: recipients see your brand, and a blocklist hit on Lumail's shared fallback cannot take your mail down.

<DocsTella
  src="https://www.tella.tv/video/vid_cmtqt2u5j007q0agm9qtxgecv/embed?b=0&title=0&a=1&loop=0&t=0&muted=0&wt=0"
  title="How to set up a custom tracking domain"
/>

## What a tracking domain does

A tracking domain is the hostname Lumail uses for the links it adds to an email:

- Tracked links first open `/r/...`, record the click, then redirect to the original destination.
- The invisible open-tracking pixel loads from `/o/...` when open tracking is enabled.
- The online version loads from `/e/...`.
- Unsubscribe links use the same hostname.

Without a custom domain, Lumail provides a per-organization hostname on its [shared tracking domain](/docs/domains/click-domains-shared). Using your own verified web domain keeps these links recognizable and aligns them with the domain you already send from.

## Set up a custom tracking domain

### 1. Add a web domain

Open **Domains**, find **Web domains**, and click **Add web domain**. Use a dedicated subdomain such as `click.yourdomain.com` or `links.yourdomain.com`.

![Add a web domain for click tracking](/docs/domains/click-domain-add-web-domain.png)

Your tracking domain is also a web domain, so it can continue serving capture pages on the same host.

### 2. Configure DNS and wait for HTTPS

Add the CNAME record shown by Lumail to your DNS provider. Keep the TTL on **Auto** when that option is available.

Return to Lumail and click **Check DNS records**. The domain is ready when DNS, the certificate, and HTTPS are all verified.

![Verified CNAME and HTTPS for a Lumail web domain](/docs/domains/click-domain-dns-verification.png)

DNS propagation is not instant. If the record is correct but still pending, wait a few minutes and check again. See [Web domains](/docs/domains/web-domains) for Cloudflare and SSL troubleshooting.

### 3. Select the domain

Open **Settings → Configuration → Tracking domains**. Choose the verified web domain.

![Select a verified web domain for tracked links](/docs/domains/click-domain-select-domain.png)

The selected host is used for new tracked links, opens, online versions, and unsubscribe URLs. Capture pages already using that web domain are unchanged.

### 4. Test a tracked link

Send a test email containing a normal link. Open the message and inspect or click the link:

1. The URL should start with your custom hostname, for example `https://click.yourdomain.com/r/...`.
2. Opening it should redirect to the original destination.
3. The click should appear in the email or campaign analytics.

Already-sent emails keep their original tracking hostname and continue to work.

## Switch back to shared tracking

Open **Settings → Configuration → Tracking domains** and choose **Lumail shared tracking**. Only newly generated links switch back to the shared hostname.

Read [Tracking domains: Shared](/docs/domains/click-domains-shared) to understand how the fallback works and what changes when you use it.

## Quick checklist

- The web domain is verified.
- HTTPS is ready.
- The domain is selected in **Settings → Configuration → Tracking domains**.
- A test email uses your hostname and redirects correctly.
- The hostname is not blocklisted.

---

# Tracking domains: Shared
Source: https://lumail.io/docs/domains/click-domains-shared.md

A tracking domain is the hostname used for tracked links, open pixels, online email versions, and unsubscribe URLs. When you have not selected a custom tracking domain, Lumail provides one automatically through its **shared tracking domain**.

There is nothing to configure. Every organization receives its own stable hostname on Lumail's current shared tracking zone.

## What recipients see

A normal destination such as:

```text
https://yourwebsite.com/pricing
```

is represented in the email by a tracked URL similar to:

```text
https://a1b2c3d4e5.shared-zone.example/r/link-id
```

The ten-character label identifies the organization inside Lumail's tracking system. It is derived from the organization's stable ID, not its editable slug, so renaming an organization does not break previously sent links.

The exact shared zone can change as Lumail maintains its tracking infrastructure. Previously sent links remain served on their original hostname.

## What happens when someone clicks

```text
Recipient clicks the tracked URL
            ↓
Lumail receives /r/link-id
            ↓
The click is attached to the email and subscriber
            ↓
Lumail redirects to the original destination
```

The shared domain does not replace or modify the destination website. It provides the short redirect step needed to measure the click before the browser continues to the original URL.

## What uses the tracking domain

| Path              | Purpose                                                       |
| ----------------- | ------------------------------------------------------------- |
| `/r/...`          | Records a tracked link click and redirects to its destination |
| `/o/...`          | Loads the open-tracking pixel when open tracking is enabled   |
| `/e/...`          | Opens the hosted online version of an email                   |
| `/unsubscribe...` | Processes the recipient's unsubscribe request                 |

All four use the same selected origin: your custom tracking domain when one is active, otherwise your shared Lumail hostname.

## Why each organization gets its own hostname

Lumail does not mint every organization's links on one identical hostname. The per-organization label provides host-level separation and allows Lumail to attribute traffic or investigate unusual activity without relying on an organization slug that can change.

This is useful isolation, but it is not the same as owning the registrable domain. Reputation systems can still evaluate the shared parent domain and affect every organization beneath it.

## Shared versus custom

|                          | Shared tracking domain             | Custom tracking domain                        |
| ------------------------ | ---------------------------------- | --------------------------------------------- |
| Setup                    | Automatic                          | Requires a verified web domain and CNAME      |
| Visible brand            | Lumail-managed hostname            | Your hostname, such as `click.yourdomain.com` |
| Organization isolation   | Separate per-organization hostname | Fully separate hostname under your domain     |
| Parent-domain reputation | Shared with other Lumail customers | Aligned with your own domain                  |
| Existing links           | Continue working after you switch  | Continue working if you switch back           |

The shared option is useful while getting started and prevents tracking from being blocked by missing DNS setup. A custom tracking domain is recommended before sending at meaningful volume because recipients see your brand and you control the parent domain's reputation.

## Switch to your own domain

1. Add and verify a web domain such as `click.yourdomain.com`.
2. Open **Settings → Configuration → Tracking domains**.
3. Select the verified domain.
4. Send a test email and verify that its tracked links use your hostname.

Follow the visual [custom tracking domain tutorial](/docs/domains/click-domains) for the video, screenshots, DNS setup, and final verification.

## Switch back to shared

In **Settings → Configuration → Tracking domains**, choose **Lumail shared tracking**. Newly generated links use the shared per-organization hostname again. Already-sent links keep their original hostname and remain valid.

## What the shared domain does not do

- It does not change the destination behind a link.
- It does not bypass consent, unsubscribe, or deliverability requirements.
- It does not make every open reliable; inbox privacy features and image blocking can affect open tracking.
- It does not isolate the reputation of the shared parent domain as strongly as a custom domain.

Treat clicks as a useful engagement signal, not proof that a human read the entire email. Security scanners and inbox providers can also visit links automatically.

---

# Domains Overview
Source: https://lumail.io/docs/domains.md

Lumail.io uses two types of domains to power your email marketing:

## [Email Domains](/docs/domains/email-domains)

Send emails from your own domain (e.g., `hello@yourdomain.com`) instead of a shared domain. This improves deliverability and brand trust.

## [Web Domains](/docs/domains/web-domains)

Serve your capture pages on a custom domain (e.g., `pages.yourdomain.com`) for a seamless brand experience.

## [Tracking domains](/docs/domains/click-domains)

Reuse a verified web domain for tracked clicks, opens, and unsubscribe links. This is the real reputation fix; the shared Lumail fallback is only for orgs that have not set one.

## Domain Limits

Your plan determines how many domains you can add. Both email and web domains count toward your total domain limit.

| Plan     | Domain Limit |
| -------- | ------------ |
| Free     | 1            |
| Premium  | 5            |
| Business | Unlimited    |

---
