# Broadcasts

A broadcast is a marketing email, such as a newsletter, a sale or a product update, sent to every contact of a segment. Ha Tang turns it into one email per contact, fills in each contact's name and properties, skips people who unsubscribed and adds an unsubscribe link and a one-click `List-Unsubscribe` header.

## Before you start

- A verified domain for the `From` address. See [Domains](https://hatang.dev/docs/guides/email/domains.md).
- A segment with contacts, and topics if recipients should be able to pick what they receive. See [Contacts, segments and topics](https://hatang.dev/docs/guides/email/audiences.md).
- For the API, a key with `email:write`.

## How a broadcast picks its recipients

A contact receives the broadcast when all of these are true:

1. The contact is in the broadcast's segment.
2. The contact has not unsubscribed from everything.
3. If the broadcast has a topic: the contact is subscribed to it. For an `opt_in` topic, that means the contact has not left it; for an `opt_out` topic, the contact must have joined it.
4. The address is not on the suppression list (hard bounce, complaint, manual block). Suppressed addresses are skipped and reported as bounced. See [Suppressions and bounces](https://hatang.dev/docs/guides/email/suppressions-and-bounces.md).

## Compose

In the dashboard, open **Email → Broadcasts → New broadcast**:

- **Segment**: who receives it. **Topic**: optional; **No topic (everyone subscribed)** sends to every subscribed contact of the segment.
- **Internal name**, **From** and **Subject**.
- **Start from a template** copies a published template's HTML, text, sender and subject into the broadcast.
- The HTML body and an optional **Plain text** version, with **Preview**.

Save it with **Create draft**. You can change a broadcast while it is a draft or scheduled.

### Merge tags

Merge tags are filled for each contact:

| Tag | Value |
|---|---|
| `{{{FIRST_NAME}}}`, `{{{LAST_NAME}}}`, `{{{EMAIL}}}` | The contact's name and address. |
| `{{{contact.first_name}}}`, `{{{contact.plan}}}`… | Contact fields and custom properties. |
| `{{{UNSUBSCRIBE_URL}}}` | The contact's preferences page. `{{{RESEND_UNSUBSCRIBE_URL}}}` is accepted too. |

Add a default after `|`: `Hi {{{FIRST_NAME|there}}}`. A property without a value uses its fallback. A tag with no value at all becomes empty; it does not stop the send. Values are HTML-escaped in the HTML body.

```html
<p>Hi {{{FIRST_NAME|there}}},</p>
<p>Your {{{contact.plan|free}}} plan now includes 10 GB of storage.</p>
<p><a href="{{{UNSUBSCRIBE_URL}}}">Unsubscribe or choose what you receive</a></p>
```

### Create with the API

**cURL**

```bash
curl -X POST https://api.hatang.dev/broadcasts \
  -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \
  -d '{
    "name": "October newsletter",
    "segment_id": "aud_0199e6c17a2b7f009c1e3f6a2b1d9e10",
    "topic_id": "topic_0199e6c17a2c7c418f0e61f1b2a3c4d5",
    "from": "Acme <news@updates.example.com>",
    "subject": "What is new in October, {{{FIRST_NAME|friend}}}",
    "html": "<p>Hi {{{FIRST_NAME|there}}}, …</p><p><a href=\"{{{UNSUBSCRIBE_URL}}}\">Unsubscribe</a></p>"
  }'
# { "object": "broadcast", "id": "bc_…" }
```

**Node.js**

```ts
const { data, error } = await hatang.broadcasts.create({
  name: "October newsletter",
  segment_id: "aud_0199e6c17a2b7f009c1e3f6a2b1d9e10",
  topic_id: "topic_0199e6c17a2c7c418f0e61f1b2a3c4d5",
  from: "Acme <news@updates.example.com>",
  subject: "What is new in October, {{{FIRST_NAME|friend}}}",
  html: '<p>Hi {{{FIRST_NAME|there}}}, …</p><p><a href="{{{UNSUBSCRIBE_URL}}}">Unsubscribe</a></p>',
});
```

**Python**

```python
res = requests.post(
    "https://api.hatang.dev/broadcasts",
    headers={"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}"},
    json={
        "name": "October newsletter",
        "segment_id": "aud_0199e6c17a2b7f009c1e3f6a2b1d9e10",
        "from": "Acme <news@updates.example.com>",
        "subject": "What is new in October",
        "html": '<p>Hi {{{FIRST_NAME|there}}}, …</p><p><a href="{{{UNSUBSCRIBE_URL}}}">Unsubscribe</a></p>',
    },
)
broadcast_id = res.json()["id"]
```

**PHP**

```php
$res = $client->post('/broadcasts', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')],
    'json' => [
        'name' => 'October newsletter',
        'segment_id' => 'aud_0199e6c17a2b7f009c1e3f6a2b1d9e10',
        'from' => 'Acme <news@updates.example.com>',
        'subject' => 'What is new in October',
        'html' => '<p>Hi {{{FIRST_NAME|there}}}, …</p><p><a href="{{{UNSUBSCRIBE_URL}}}">Unsubscribe</a></p>',
    ],
]);
$broadcastId = json_decode($res->getBody(), true)['id'];
```

| Field | Description |
|---|---|
| `segment_id` | Required. The segment to send to. `audience_id` is the older name and still works. |
| `topic_id` | Optional. Only contacts subscribed to this topic. |
| `template_id` | Optional. Copy `html`, `text`, `from`, `subject` and `reply_to` from this published template. Fields you send win. |
| `from` | Required unless the template sets it. |
| `subject` | Required unless the template sets it. Up to 998 characters. |
| `reply_to` | One address or a list. |
| `html`, `text` | The body. At least one is needed before you send. |
| `name` | Internal name, up to 200 characters. Defaults to the subject. |

The template is copied when the broadcast is created; publishing a new template version later does not change it.

## Send or schedule

In the editor, the **Send** box takes `now`, an ISO date or a phrase such as `in 2 hours`. Save your changes before you click **Send broadcast**. With the API:

**cURL**

```bash
# Now
curl -X POST https://api.hatang.dev/broadcasts/$BROADCAST_ID/send -H "Authorization: Bearer $HATANG_API_KEY"

# Later
curl -X POST https://api.hatang.dev/broadcasts/$BROADCAST_ID/send \
  -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \
  -d '{ "scheduled_at": "2026-10-20T09:00:00+07:00" }'
# { "object": "broadcast", "id": "bc_…", "status": "scheduled" }
```

**Node.js**

```ts
await hatang.broadcasts.send(broadcastId); // now
await hatang.broadcasts.send(broadcastId, { scheduledAt: "in 2 hours" }); // later
```

**Python**

```python
headers = {"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}"}
requests.post(f"https://api.hatang.dev/broadcasts/{broadcast_id}/send", headers=headers, json={"scheduled_at": "in 2 hours"})
```

**PHP**

```php
$client->post("/broadcasts/$broadcastId/send", [
    'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')],
    'json' => ['scheduled_at' => 'in 2 hours'],
]);
```

`scheduled_at` takes the same values as for single emails: an ISO 8601 date up to 30 days ahead, or `in N minutes|hours|days`. Sending a scheduled broadcast again replaces its schedule.

`POST /broadcasts/{id}/cancel` stops a broadcast that is scheduled or still queued. A broadcast that is sending cannot be canceled or deleted.

| Status | Meaning |
|---|---|
| `draft` | Not sent yet. You can edit it. |
| `scheduled` | Waiting for its time. You can still edit or cancel it. |
| `queued` | About to start. |
| `sending` | Emails are being created for each contact. |
| `sent` | Every recipient has been handled. |
| `canceled` | Canceled before it started. |

`GET /broadcasts/{id}` shows `total` (contacts selected) and `sent_count` (emails created). Each email of a broadcast is a normal email with the tag `broadcast_id`, so you can follow it under **Email → Emails** (filter **Broadcast**), in `GET /emails?source=broadcast` and through the usual webhooks. If sending is interrupted, Ha Tang picks up where it stopped and never sends a contact the same broadcast twice.

> **Caution: Quota**
>
> Every email of a broadcast counts toward your daily quota. A broadcast to more contacts than the quota you have left stops sending when the quota runs out; the remaining contacts are marked as failed. Check **Email settings** before a large send.

## Unsubscribe {#unsubscribe}

Every broadcast email carries two ways out:

- The `{{{UNSUBSCRIBE_URL}}}` link in your content opens the hosted preferences page, where the contact picks topics or unsubscribes from everything. Opening it changes nothing by itself.
- A `List-Unsubscribe` header with `List-Unsubscribe-Post: List-Unsubscribe=One-Click` (RFC 8058), so Gmail, Apple Mail and others show their own unsubscribe button. One click removes the contact from the broadcast's topic, or from everything when the broadcast has no topic.

Ha Tang adds the header for you. Put the link in your content as well: some mail clients do not show the button, and the law in many countries requires a visible way to unsubscribe.

When a contact unsubscribes from everything, they stop receiving broadcasts and automation emails, and their address is added to the suppression list with the reason `unsubscribe`, which also blocks other emails to it. See [Suppressions and bounces](https://hatang.dev/docs/guides/email/suppressions-and-bounces.md).

Brand the preferences page under **Email settings → Unsubscribe page**: title, description, logo and colors.

## API reference

| Endpoint | Description |
|---|---|
| `POST /broadcasts` | Create a draft. Returns `201` with the id. |
| `GET /broadcasts`, `GET /broadcasts/{id}` | List, or get one with `status`, `total`, `sent_count`, `scheduled_at` and `sent_at`. |
| `PATCH /broadcasts/{id}` | Change a draft or scheduled broadcast. |
| `POST /broadcasts/{id}/send` | Send now or schedule (`scheduled_at`). |
| `POST /broadcasts/{id}/cancel` | Cancel a scheduled or queued broadcast. |
| `DELETE /broadcasts/{id}` | Delete it, unless it is sending. |

## Next steps

- [Contacts, segments and topics](https://hatang.dev/docs/guides/email/audiences.md)
- [Templates](https://hatang.dev/docs/guides/email/templates.md)
- [Deliverability](https://hatang.dev/docs/guides/email/deliverability.md)
- [Metrics and logs](https://hatang.dev/docs/guides/email/metrics-and-logs.md)

Source: https://hatang.dev/docs/guides/email/broadcasts
