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
Fromaddress. See Domains. - A segment with contacts, and topics if recipients should be able to pick what they receive. See Contacts, segments and topics.
- 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:
- The contact is in the broadcast's segment.
- The contact has not unsubscribed from everything.
- If the broadcast has a topic: the contact is subscribed to it. For an
opt_intopic, that means the contact has not left it; for anopt_outtopic, the contact must have joined it. - 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.
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.
<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
- Node.js
- Python
- PHP
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 <[email protected]>",
"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_…" }
const { data, error } = await hatang.broadcasts.create({
name: "October newsletter",
segment_id: "aud_0199e6c17a2b7f009c1e3f6a2b1d9e10",
topic_id: "topic_0199e6c17a2c7c418f0e61f1b2a3c4d5",
subject: "What is new in October, {{{FIRST_NAME|friend}}}",
html: '<p>Hi {{{FIRST_NAME|there}}}, …</p><p><a href="{{{UNSUBSCRIBE_URL}}}">Unsubscribe</a></p>',
});
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",
"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"]
$res = $client->post('/broadcasts', [
'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')],
'json' => [
'name' => 'October newsletter',
'segment_id' => 'aud_0199e6c17a2b7f009c1e3f6a2b1d9e10',
'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
- Node.js
- Python
- PHP
# 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" }
await hatang.broadcasts.send(broadcastId); // now
await hatang.broadcasts.send(broadcastId, { scheduledAt: "in 2 hours" }); // later
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"})
$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.
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
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-Unsubscribeheader withList-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.
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. |