---
name: clickfunnels-emails
description: >
  Author and manage marketing email programmatically - email templates (create,
  update, delete, with html_body/text_body), one-off broadcasts, and the
  sending domains and addresses they go out from. Use this skill when you need
  to create an email template via the API, send a broadcast, choose an address
  whose usable_as_sender flag is true, or attach an inline HTML email body to a
  workflow send-email step.
version: "1.0"
author: ClickFunnels
tools:
  - http
references:
  - path: /llms.txt
    description: Quick-reference index - product overview, login, funnels, API docs.
  - path: /.well-known/workflows/skill.md
    description: Companion - workflow send_email_step can carry an inline html_body that creates a template (this skill documents the template side).
  - path: /.well-known/refine-filters/skill.md
    description: Companion - build the audience filters a broadcast or workflow targets.
---

# ClickFunnels Emails Skill

A discovery hub for the email-content API: reusable **templates** (the HTML/text
body an email pulls from), **broadcasts** (a one-off send to an audience), and the
**addresses** an email is sent from. Templates created through the API are usable
for sending and previews, but are hidden from the visual template pickers and
cannot be edited in the visual editor - they are managed entirely
through the API.

## When to use this skill

- Create, update, or delete an email **template** from raw HTML/text.
- Provision and verify a custom email-sending **domain**.
- List a workspace's sending **addresses** and pick one whose `usable_as_sender` is true.
- Configure the workspace's business mailing address for marketing email.
- Create, update, or delete an email **broadcast**.
- Attach an inline HTML body to a workflow **send-email step** (see the Workflows skill).

## Authentication

OAuth2 with an application's access token. Send it as a bearer token:

```http
Authorization: Bearer <access_token>
```

Reads (list/fetch) and writes are available to any token authorized for the
workspace.

## Endpoint reference

| Action | Method | Path |
|---|---|---|
| List templates | GET | `/api/v2/workspaces/{workspace_id}/emails/templates` |
| Fetch template | GET | `/api/v2/emails/templates/{id}` |
| Create template | POST | `/api/v2/workspaces/{workspace_id}/emails/templates` |
| Update template | PATCH | `/api/v2/emails/templates/{id}` |
| Delete template | DELETE | `/api/v2/emails/templates/{id}` |
| List addresses | GET | `/api/v2/workspaces/{workspace_id}/emails/addresses` |
| Create address | POST | `/api/v2/workspaces/{workspace_id}/emails/addresses` |
| Fetch address | GET | `/api/v2/emails/addresses/{id}` |
| Update address | PATCH | `/api/v2/emails/addresses/{id}` |
| Delete address | DELETE | `/api/v2/emails/addresses/{id}` |
| List sending domains | GET | `/api/v2/workspaces/{workspace_id}/emails/domains` |
| Provision sending domain | POST | `/api/v2/workspaces/{workspace_id}/emails/domains` |
| Fetch sending domain | GET | `/api/v2/emails/domains/{id}` |
| Verify sending domain | POST | `/api/v2/emails/domains/{id}/verify` |
| List broadcasts | GET | `/api/v2/workspaces/{workspace_id}/emails/broadcasts` |
| Create broadcast | POST | `/api/v2/workspaces/{workspace_id}/emails/broadcasts` |
| Update broadcast | PATCH | `/api/v2/emails/broadcasts/{id}` |
| Delete broadcast | DELETE | `/api/v2/emails/broadcasts/{id}` |
| Send/schedule broadcast | POST | `/api/v2/emails/broadcasts/{broadcast_id}/send_actions` |
| List send actions | GET | `/api/v2/emails/broadcasts/{broadcast_id}/send_actions` |
| Fetch send action | GET | `/api/v2/emails/broadcasts/send_actions/{id}` |
| Fetch broadcast stats | GET | `/api/v2/emails/broadcasts/{broadcast_id}/stats` |
| Cancel scheduled send | DELETE | `/api/v2/emails/broadcasts/send_actions/{id}` |
| Show settings | GET | `/api/v2/workspaces/{workspace_id}/emails/settings` |
| Update settings | PUT | `/api/v2/workspaces/{workspace_id}/emails/settings` |

## Business mailing address prerequisite

Before you can create a broadcast or workflow `send_email_step`, the workspace must
have a complete business mailing address. Check `address_complete` first:

```http
GET /api/v2/workspaces/5/emails/settings
```

If it is `false`, set both the business name and address. This is the same endpoint
named in the `422` response from either create operation:

```http
PUT /api/v2/workspaces/5/emails/settings
Content-Type: application/json

{
  "marketing_setting": {
    "name": "Acme Inc.",
    "address": {
      "address_one": "123 Main St",
      "city": "San Francisco",
      "postal_code": "94105",
      "country_code": "US",
      "region": "CA"
    }
  }
}
```

A successful response has `address_complete: true`. Creating a broadcast or workflow
send-email step while it is incomplete returns `422` and creates nothing.

### Footer and branding settings

`GET` and successful `PUT` responses include these readable and writable settings:

| Field | Type | Meaning |
|---|---|---|
| `name` | string or null | Business/sender name shown on email broadcasts |
| `hide_branding` | boolean | Whether ClickFunnels branding is hidden in the email footer |
| `unsubscribe_footer` | string or null | Custom unsubscribe footer text |
| `footer_alignment` | string | Footer alignment: `left`, `center`, or `right` |
| `address_single_line` | boolean | Whether the marketing address renders on one line |

Only fields included in a `PUT` are changed. For example:

```http
PUT /api/v2/workspaces/5/emails/settings
Content-Type: application/json

{
  "marketing_setting": {
    "name": "Acme Inc.",
    "hide_branding": true,
    "unsubscribe_footer": "Unsubscribe here",
    "footer_alignment": "left",
    "address_single_line": true
  }
}
```

`footer_alignment` must be one of the three documented values. An empty string,
whitespace, `null`, or any other value returns `422`; omit the field to leave the
current alignment unchanged.

## Templates

A template carries `subject`, an HTML body (`html_body`) and a plain-text body
(`text_body`), and an optional `from_address`. Provide the body as
`html_body`/`text_body` on write. On **read** the body fields are **heavy** -
`html_body` is a per-template cloud-storage read - so they are returned only when
you ask for them with `expand[]=html_body` / `expand[]=text_body`; list and show
omit them by default (so a bare list never fans out to one read per row). This
works on writes too: pass the expand params on the `POST`/`PATCH` to get the body
echoed back in the response.

### Create a template

```http
# expand[]=html_body/text_body so the response echoes the body you just set
# (omit them and those two fields are absent from the response).
POST /api/v2/workspaces/5/emails/templates?expand[]=html_body&expand[]=text_body
Content-Type: application/json

{
  "emails_template": {
    "name": "Welcome Email",
    "subject": "Hello",
    "html_body": "<h1>Hi</h1>",
    "text_body": "Hi",
    "from_address_id": 12
  }
}
```

```json
{
  "id": 111,
  "public_id": "OezZVe",
  "workspace_id": 5,
  "name": "Welcome Email",
  "template_type": "email",
  "created_at": "2026-06-29T14:44:18.176Z",
  "updated_at": "2026-06-29T14:44:18.271Z",
  "subject": "Hello",
  "html_body": "<h1>Hi</h1>",
  "text_body": "Hi",
  "created_via_api": true,
  "from_address": {
    "id": 12,
    "public_id": "EjRpNb",
    "email_address": "marketing@example.com",
    "name": "My Team"
  }
}
```

Notes:

- `created_via_api` is `true` for templates made through this API. They are hidden
  from the visual template pickers and cannot be opened in the visual editor.
- `subject` may be blank.
- `from_address_id` must belong to the same workspace, or you get a `422`. Use an
  address whose `usable_as_sender` is true (see Addresses below).
- `template_type` is always `email`; passing any other value returns `422`.

### Update a template

Only the fields you send are changed; the rest are left intact.

```http
PATCH /api/v2/emails/templates/111
Content-Type: application/json

{ "emails_template": { "html_body": "<h1>Updated</h1>" } }
```

Invalid Liquid in `html_body` is rejected with `422` on update.

### Delete a template

```http
DELETE /api/v2/emails/templates/111
```

Returns `204` when the template is unused. Returns `422` if the template is shared
or is still referenced by a workflow send-email step (remove the step first).

## Sending domains

A sending domain is the part after the `@` of a custom sender address. Before
anything can be sent from it, that one domain name has to do **two independent
jobs**, proved in completely different ways. Sending needs both:

```
                       "example.com"
                              |
        +---------------------+----------------------+
        |                                            |
  WEBSITE DOMAIN                              SENDING DOMAIN
  connected in ClickFunnels                   (this API)
        |                                            |
  proves the workspace                        authenticates email
  owns the name                               transport
        |                                            |
  the customer connects it                    DKIM + SPF + DMARC records
  in the ClickFunnels app;                    at the DNS provider
  there is NO API route                       (+ a recommended return-path
  for this step                                CNAME), or a verified SMTP
        |                                      setting
        |                                            |
  "ownership_verified": true                  "verified": true
        |                                            |
        +---------------------+----------------------+
                              |
                   "ready_for_sending": true
                              |
             an address on this domain then reports
                   "usable_as_sender": true
```

Neither half substitutes for the other. Perfect DNS records do not prove
ownership, and connecting the website domain does not authenticate transport.
**`ready_for_sending` is the flag to poll; when it turns `true`, stop.** Check it
at most once every 10 minutes (results are cached for about that long, so asking
sooner returns the same answer), and do not sit in a polling loop: both of the
remaining steps need a human. After a check or two, hand the outstanding
`next_steps` back to the user and re-check when they tell you they are done.

**Provision the root domain (`example.com`), not a subdomain
(`news.example.com`).** Ownership is confirmed at the root domain: a website
domain is always connected as a subdomain (`www.example.com`, `news.example.com`,
`shop.example.com`), and connecting any of them confirms ownership of the one
sending domain `example.com`. Because nothing could ever confirm ownership of a
subdomain sending domain, provisioning one is rejected with `422` naming the root
domain to use instead:

```json
{"error": "Request unprocessable: Sending domains are set up on the root domain. Use \"example.com\" instead of \"news.example.com\"."}
```

Sender addresses are created at the sending domain's own name, so a root sending
domain gives you `hello@example.com`.

Sending domains cannot be renamed or removed through this API, so confirm the
exact spelling with the customer before the first `POST`.

### End-to-end flow

**Step 1 - provision the sending domain.**

```http
POST /api/v2/workspaces/5/emails/domains
Content-Type: application/json

{"emails_domain": {"name": "example.com"}}
```

```json
{
  "id": 11,
  "public_id": "OezDJd",
  "workspace_id": 5,
  "name": "example.com",
  "verified": false,
  "dkim_verified": false,
  "spf_verified": false,
  "dmarc_verified": false,
  "smtp_verified": false,
  "ownership_verified": false,
  "ready_for_sending": false,
  "return_address_verified": false,
  "dkim_record": {"type": "TXT", "name": "second._domainkey.example.com", "value": "v=DKIM1; k=rsa; p=MIIBIjAN...", "ttl": 300, "verified": false},
  "spf_record": {"type": "TXT", "name": "example.com", "value": "v=spf1 include:mailer.myclickfunnels.com -all", "ttl": 300, "verified": false},
  "dmarc_record": {"type": "TXT", "name": "_dmarc.example.com", "value": "v=DMARC1; p=none;", "ttl": 300, "verified": false},
  "return_address_record": {"type": "CNAME", "name": "cf2mail.example.com", "value": "mailer.myclickfunnels.com", "ttl": 300, "verified": false},
  "next_steps": [
    "Connect example.com to this workspace in ClickFunnels so ownership is confirmed. Addresses on this domain cannot send until it is, even once every record below verifies.",
    "Add dkim_record, spf_record, and dmarc_record at the DNS provider for example.com to authenticate email transport.",
    "Recommended for ClickFunnels bounce handling: add return_address_record at the DNS provider.",
    "DNS changes can take up to 48 hours to propagate. Re-check with POST /api/v2/emails/domains/OezDJd/verify; results are cached for about 10 minutes.",
    "Manual setup help: https://support.myclickfunnels.com/docs/email-settings-how-to-verify-email-dns-records"
  ],
  "created_at": "2026-06-29T14:44:18.176Z",
  "updated_at": "2026-06-29T14:44:18.271Z"
}
```

Creating the same name twice is safe and never duplicates: `201` when the domain
was newly provisioned, `200` when the workspace already had it. Two things about
the response that are easy to miss:

- `dkim_record` is `null` when the key has not finished generating. Hand over the
  other records meanwhile, wait a minute, then POST the same name again to retry.
  Stop after a few attempts and tell the customer rather than retrying in a loop.
- Provisioning also creates a `no-reply@example.com` sender address on the new
  domain, so the workspace already has one address on it before step 5. It reports
  `usable_as_sender: false` until the domain is ready.

**Step 2 - the customer publishes those records at their DNS provider.**
Hand over `dkim_record`, `spf_record` and `dmarc_record` verbatim (`name`, `type`,
`value`). `spf_record.value` may merge the domain's existing SPF record, so send
the returned value rather than a canned one. `return_address_record` is optional
but recommended: it is what routes bounce handling back to ClickFunnels.

**Step 3 - re-check with `verify`.** This is the only call that re-checks the
verification flags; listing and fetching return the stored result.

```http
POST /api/v2/emails/domains/OezDJd/verify
```

The usual result here is the state that trips people up (records omitted for
brevity; this customer also added the optional return-path record, so the
bounce-handling recommendation has dropped out of `next_steps`):

```json
{
  "id": 11,
  "public_id": "OezDJd",
  "name": "example.com",
  "verified": true,
  "dkim_verified": true,
  "spf_verified": true,
  "dmarc_verified": true,
  "smtp_verified": false,
  "ownership_verified": false,
  "ready_for_sending": false,
  "return_address_verified": true,
  "next_steps": [
    "Connect example.com to this workspace in ClickFunnels so ownership is confirmed. Addresses on this domain cannot send until it is, even once every record below verifies.",
    "DNS changes can take up to 48 hours to propagate. Re-check with POST /api/v2/emails/domains/OezDJd/verify; results are cached for about 10 minutes.",
    "Manual setup help: https://support.myclickfunnels.com/docs/email-settings-how-to-verify-email-dns-records"
  ]
}
```

Transport is fully authenticated, yet nothing can be sent: the workspace has not
proved it owns the name, so `ready_for_sending` stays `false` and every address on
the domain still reports `usable_as_sender: false`. **Do not read `verified: true`
as "done".** A `200` from `verify` only means the check ran, not that it passed -
always read the booleans back. Results are cached for about 10 minutes (polling
faster returns the same answer), and DNS itself can take up to 48 hours to
propagate.

**Step 4 - the customer connects the domain to the workspace in ClickFunnels.**
This is the website-domain connection in the app (connected as a subdomain, e.g.
`www.example.com`, which confirms ownership of the root sending domain
`example.com`), and **there is no API route for it** - an agent cannot do it, so
ask the customer to. Any read of
the domain reflects the result as soon as they are done: `ownership_verified` and
`ready_for_sending` both turn `true`, and `next_steps` ends with a "Ready to send"
line (the recommended return-path line stays in the list until that record
verifies, so do not treat a one-element `next_steps` as the ready signal):

```json
{
  "id": 11,
  "public_id": "OezDJd",
  "name": "example.com",
  "verified": true,
  "ownership_verified": true,
  "ready_for_sending": true,
  "next_steps": [
    "Ready to send - create addresses on this domain with POST /api/v2/workspaces/5/emails/addresses (emails_domain_id 11)."
  ]
}
```

**Step 5 - create a sender address on the domain.** Pass the domain's numeric `id`
as `emails_domain_id` to `POST /api/v2/workspaces/5/emails/addresses` (full example
under [Addresses](#addresses-pick-a-usable-sender) below); the new address comes
back with `usable_as_sender: true` and can be used as a template `from_address_id`,
a broadcast `from_email`, or a workflow send-email `from_address_id`. You may
create the address earlier in the flow - it is allowed, it just reports
`usable_as_sender: false` until step 4 lands.

A usable sender is only half of what a send needs: the workspace must also have a
complete business mailing address (see [Business mailing address
prerequisite](#business-mailing-address-prerequisite)) before a broadcast or a
workflow send-email step can be created.

### What each flag means

| Field | Meaning |
|---|---|
| `verified` | Transport authenticated: DKIM + SPF + DMARC all confirmed, or a verified SMTP setting. Says nothing about ownership. |
| `ownership_verified` | The workspace proved it owns the name by connecting it, or any subdomain of it, as a website domain in ClickFunnels. No API route; ask the customer. |
| `ready_for_sending` | Both of the above. This is the flag to poll for a domain authenticated by DNS records. One exception: a domain configured with its own SMTP settings reports its addresses as `usable_as_sender: true` even while this stays `false`. |
| `dkim_verified`, `spf_verified`, `dmarc_verified` | Per-record results of the last check - use them to tell the customer exactly which record is still missing. |
| `return_address_verified` | The recommended return-path CNAME. Improves bounce handling; not required for `ready_for_sending`. |
| `smtp_verified` | The domain sends through a verified SMTP setting instead of DNS records. When `true`, `verified` is `true` even with the DNS flags `false`. |
| `next_steps` | Plain-English list of what is left for this specific domain. Safe to surface to a human as-is. |

Each DNS record object (`dkim_record`, `spf_record`, `dmarc_record`,
`return_address_record`) carries `type`, `name`, `value`, `ttl` and its own
`verified` flag. `dkim_record` is `null` until the key finishes generating;
`return_address_record` is always returned.

## Addresses (pick a usable sender)

List the workspace's sending addresses, then use only an address whose top-level
`usable_as_sender` is `true` for `from_address_id` (templates),
`from_email`/`reply_to_email` (broadcasts), or
`from_address_id`/`reply_to_address_id` (workflow send-email steps).

```http
GET /api/v2/workspaces/5/emails/addresses
```

```json
[
  {
    "id": 12,
    "email_address": "my-team-aBcDeF@clickfunnelsmail.com",
    "name": "My Team",
    "public_id": "EjRpNb",
    "workspace_id": 5,
    "is_workspace_default": true,
    "usable_as_sender": false,
    "emails_domain": null
  },
  {
    "id": 20,
    "email_address": "no-reply@example.com",
    "name": "My Team",
    "public_id": "wjVaeR",
    "workspace_id": 5,
    "is_workspace_default": false,
    "usable_as_sender": true,
    "emails_domain": {
      "name": "example.com",
      "verified": true,
      "dkim_verified": true,
      "spf_verified": true,
      "dmarc_verified": true,
      "smtp_verified": false
    }
  }
]
```

Do not pick a sender from `emails_domain.verified` or `is_workspace_default`
alone. As the diagram above shows, `verified` only covers transport
authentication; `usable_as_sender` is the final contract, because it also
accounts for domain ownership, SMTP, and whether the shared-domain fallback is
still allowed. In the example, the ready custom sender is what makes the shared
fallback ineligible. The `no-reply@` address is the one that was created
automatically when the sending domain was provisioned.

The nested `emails_domain` object carries the transport flags only, and it does
**not** include the domain's `id`. For `ownership_verified`, `ready_for_sending`,
the DNS records or `next_steps`, match `emails_domain.name` against
`GET /api/v2/workspaces/{workspace_id}/emails/domains` and then fetch that record
with `GET /api/v2/emails/domains/{id}`. `usable_as_sender: false` while
`emails_domain.verified` is `true` almost always means ownership is still missing.

To create an address on a custom domain, pass the sending domain's numeric `id`
(step 5 of the flow above):

```http
POST /api/v2/workspaces/5/emails/addresses
Content-Type: application/json

{"emails_address": {"username": "hello", "name": "My Team", "emails_domain_id": 11}}
```

Omit `emails_domain_id` to get a managed address on the shared ClickFunnels
sending domain instead.

## Broadcasts

A broadcast is a one-off send. Provide `from_email` (required, must belong to the
workspace) and either an inline body (`html_body`/`text_body`) or an existing
`template_id`. When you pass a body without a `template_id`, a template is created
automatically from it. Creation also requires the complete business mailing address
described above, even though the new broadcast starts as a draft.

```http
POST /api/v2/workspaces/5/emails/broadcasts
Content-Type: application/json

{
  "emails_broadcast": {
    "name": "Weekly Newsletter",
    "subject": "This Week",
    "from_email": "marketing@example.com",
    "html_body": "<h1>Hello</h1>",
    "text_body": "Hello"
  }
}
```

Broadcasts are created in `draft`. `template_id` takes precedence over an inline
body when both are given. Use only `from_email`/`reply_to_email` addresses whose
list response has `usable_as_sender: true`.

While a broadcast is still `draft` you can **swap its template** by `PATCH`ing
`template_id` (the target must be a template in the same workspace; an unknown id
returns `422`). The swap wins over an inline body, same as on create. Once a
broadcast leaves `draft`, updates are rejected. Unlike a workflow send-email step
(which copies the template), a broadcast **references** the template you point it at.

On **read**, a broadcast nests the template it references as a lean `template` object -
`{ id, public_id, name }`. The rendered body is **not** inlined on the broadcast; fetch
it from the templates endpoint via that id: `GET /api/v2/emails/templates/{public_id}?expand[]=html_body`
(the templates endpoint expand-gates the body to keep its own list cheap).

### Smart Sending is on by default

A broadcast is delivered **gradually across a window** rather than all at once. That
is Smart Sending, and it protects deliverability on a large list. It is controlled by
`smart_sending`, which **defaults to `true`** - so if you create a broadcast and say
nothing, it batches, and the window is sized for you from the size of the audience.
A six-figure list spread over about an hour is a typical result. Leaving the sizing
alone is the recommended default.

To deliver the whole audience in one go, send `smart_sending: false` explicitly:

```http
PATCH /api/v2/emails/broadcasts/{id}
Content-Type: application/json

{ "emails_broadcast": { "smart_sending": false } }
```

To size the window yourself, send `smart_sending_time` (0.5 to 100) together with
`smart_sending_unit` (`hour` or `day`). This spreads the send over two hours:

```json
{
  "emails_broadcast": {
    "smart_sending": true,
    "smart_sending_time": 2,
    "smart_sending_unit": "hour"
  }
}
```

Both read back as `null` until you set them, which means the window is being sized
from the audience. Send both as `null` to hand the sizing back. All three fields are
settable on create and on update while the broadcast is still `draft`, and all three
are returned on read.

Three rules worth knowing before you rely on this:

- Above **5,000 recipients** Smart Sending is mandatory. `smart_sending: false`
  is accepted and may be stored as `true`. The send batches at that size regardless
  of the stored value, the same way the dashboard does not let you turn it off there.
- The time and the unit are **one setting in two fields**. Setting one without
  the other returns `422` rather than guessing at the half you left out.
- An unknown `smart_sending_unit`, or a `smart_sending_time` outside 0.5 to 100,
  returns `422`.

### Sending or scheduling a broadcast

Creating a broadcast only drafts it. To actually send it, create a **send action**
against it. Omit `send_at` to send now; pass a future `send_at` (date-time) to
schedule. The broadcast must be ready to send: it needs a topic, a usable
`from_email`, and a complete marketing (physical) address on the workspace's email
settings - otherwise you get a `422`.

```http
POST /api/v2/emails/broadcasts/{broadcast_id}/send_actions
Content-Type: application/json

{ "emails_broadcasts_send_action": { "send_at": "2026-07-15T18:00:00Z" } }  # omit send_at to send now
```

Returns **201 Created** with the send action; it runs asynchronously, so fetch it
at `GET /api/v2/emails/broadcasts/send_actions/{id}` for `started_at`/`completed_at`.
`DELETE /api/v2/emails/broadcasts/send_actions/{id}` cancels a scheduled send.

### Split tests and results

A broadcast can be a subject-line split test. On read, every broadcast carries
`split_test`, `split_test_percentage` (the share of the audience used for the test
sends), `split_test_wait_time` (hours before the winner is picked), and a `variants`
array, oldest first. Each variant has `subject`, `preheadline`, `send_percentage`, `main` (the
variant that mirrors the broadcast's own subject line), `open_count` (opens counted
while the test ran, frozen when the winner is picked), and `winner`.

`winner` is `null` while the test is still running, and for a broadcast that is not a
split test. Once the test closes it is `true` for exactly one variant (the most opens,
the main variant on a tie) and `false` for the others. A broadcast that is not a split
test lists one variant, its main one. A broadcast created before subject-line split
tests existed and already sent has no variant at all: `variants` is `[]`, and the
broadcast's own `subject` and `preheadline` are the only copy of its subject line, so
fall back to those rather than reading `variants[0]`.

```json
{
  "split_test": true,
  "split_test_percentage": 20.0,
  "split_test_wait_time": 4,
  "variants": [
    { "subject": "This Week", "main": true, "send_percentage": 10.0, "open_count": 118, "winner": false },
    { "subject": "Don't miss this week", "main": false, "send_percentage": 10.0, "open_count": 174, "winner": true }
  ]
}
```

Split tests are configured in the ClickFunnels dashboard; the API reads them but does
not create them.

### Delivery and engagement stats

Delivery progress, opens, clicks, bounces, unsubscribes, spam complaints, and
attributed revenue are NOT on the broadcast record. They come from a separate
endpoint:

```http
GET /api/v2/emails/broadcasts/{broadcast_id}/stats
```

It returns `progress` (total, queued, processing, sent, failed), `performance`
(counts plus whole-percentage rates), and `revenue` (decimal strings in `currency`),
over the broadcast's send window (`timerange`): from the first send until two weeks
after completion, or until now for a broadcast that has not completed, capped at 90
days after the first send (a broadcast that has not started sending reports the last 90
days, all zeros). There are no timerange parameters; the funnel-style
`timerange_start`/`timerange_end` are refused with `422` rather than silently ignored.
Counts are unique contacts except `opened` and `clicked`, which count every event.
Rates round to whole percentages, so a share under 0.5% reads `0` next to a non-zero
count; the counts are authoritative. `queued` is every contact in a batch that has not
started sending, including batches paused or halted by a health check. `progress.total`
is the audience across every send of the broadcast and `null` for one that has not
started sending. Revenue is attributed to a broadcast when the buyer reached the order
through one of its links; only live-mode transactions in the workspace currency count
(a workspace with no currency set has `currency: null` and every currency summed), and
`orders_count` is orders, not transactions.

## Inline body on a workflow send-email step

A workflow send-email step can take an inline `html_body`/`text_body` instead of a
pre-made `template_id`. The step mints its own template from that body. If you pass
both a `template_id` and a body, the `template_id` wins and the inline body is
ignored. Creating the step requires the complete business mailing address described
above. See the [Workflows skill](/.well-known/workflows/skill.md).

```http
POST /api/v2/workflows/{workflow_id}/steps
Content-Type: application/json

{
  "workflows_step": {
    "step_type_settings": {
      "send_email_step": {
        "subject": "Welcome",
        "html_body": "<h1>Welcome</h1>",
        "from_address_id": 12
      }
    }
  }
}
```

```json
{
  "id": 1206,
  "public_id": "jPoomJ",
  "workflow_id": 186,
  "step_type": "send_email_step",
  "state": "active",
  "step_type_settings": {
    "send_email_step": {
      "subject": "Welcome",
      "preheadline": null,
      "template_id": 96,
      "from_address_id": 12,
      "reply_to_address_id": null
    }
  }
}
```

`html_body`/`text_body` are write-only - they are never returned in
`step_type_settings`; the created `template_id` is.

A send-email step always owns its template. If step creation sends template id A,
ClickFunnels copies it and the response's `step_type_settings.send_email_step.template_id`
is clone B. Later edits to A do not affect the step. `template_id` is create-only: a
later step `PATCH` that includes it returns `422`. To change the step's email, send
`html_body`/`text_body`; to use a different template, create a new step.

## Worked example - template, then send it from a workflow

```
# 1) pick a usable sender
GET  /api/v2/workspaces/5/emails/addresses          -> choose an address with usable_as_sender: true

# 2) author a template
POST /api/v2/workspaces/5/emails/templates
     { "emails_template": { "name": "Welcome", "subject": "Hi", "html_body": "<h1>Hi</h1>", "from_address_id": 12 } }
     -> capture template id

# 3) reference it from a workflow send-email step
POST /api/v2/workflows/<workflow_pid>/steps
     { "workflows_step": { "step_type_settings": { "send_email_step": { "template_id": <id>, "from_address_id": 12, "subject": "Hi" } } } }
```

## Common errors

| Error | Cause | Fix |
|---|---|---|
| 422 "A complete business mailing address is required..." | Email settings have no complete business name/address | Use `PUT /api/v2/workspaces/{workspace_id}/emails/settings`, then retry |
| 422 "not a valid footer_alignment" | `footer_alignment` is blank, null, or not `left`, `center`, or `right` | Send one of the allowed values, or omit the field to keep the current setting |
| 422 "from_address_id not found" | `from_address_id` is not in this workspace | Use an address id from `GET .../emails/addresses` |
| 422 "template_type must be 'email'" | Sent a non-email `template_type` | Omit `template_type` (it is always `email`) |
| 422 "Liquid syntax error: ..." | Invalid Liquid in `html_body` on update | Fix the Liquid markup |
| 422 "Template cannot be deleted ..." | Template is shared or used by a workflow step | Remove the referencing step first, then delete |
