Broadcasts
One email, sent once, to the members of chosen customer groups and offerings
A broadcast is one email, sent once, to the members of chosen customer groups and offerings, like Klaviyo’s “send to” and “don’t send to”. A newsletter is a free group people join; each issue is a broadcast to its members. A broadcast to a gym’s Premium members isn’t a newsletter, and works the same way.
import type { EpochMilliseconds } from "arky-sdk";
type BroadcastMembers =
| { type: "customer_group"; customer_group_id: string }
| { type: "customer_group_offering"; customer_group_offering_id: string };
interface Broadcast {
id: string;
store_id: string;
key: string;
audience: {
include: BroadcastMembers[];
exclude: BroadcastMembers[];
conditions: (
| { type: "ordered_product"; product_id: string }
| { type: "ordered_within"; days: number }
| { type: "not_ordered_within"; days: number }
)[];
};
sending_address_id: string;
content: Record<string, { subject: string; preheader: string | null; body: string }>;
status:
| { type: "draft" }
| { type: "scheduled"; send_at: EpochMilliseconds }
| { type: "sending"; started_at: EpochMilliseconds }
| { type: "sent"; started_at: EpochMilliseconds; recipient_count: number };
created_at: EpochMilliseconds;
updated_at: EpochMilliseconds;
}
Routes
POST
/v1/stores/{store_id}/broadcasts SDK:
sdk.broadcast.create() GET
/v1/stores/{store_id}/broadcasts SDK:
sdk.broadcast.find() GET
/v1/stores/{store_id}/broadcasts/{id} SDK:
sdk.broadcast.get() PUT
/v1/stores/{store_id}/broadcasts/{id} SDK:
sdk.broadcast.update() DELETE
/v1/stores/{store_id}/broadcasts/{id}?expected_updated_at= SDK:
sdk.broadcast.delete() POST
/v1/stores/{store_id}/broadcasts/{id}/schedule SDK:
sdk.broadcast.schedule() POST
/v1/stores/{store_id}/broadcasts/{id}/unschedule SDK:
sdk.broadcast.unschedule() POST
/v1/stores/{store_id}/broadcasts/{id}/send SDK:
sdk.broadcast.send() POST
/v1/stores/{store_id}/broadcasts/{id}/preview SDK:
sdk.broadcast.preview() POST
/v1/stores/{store_id}/broadcasts/{id}/test SDK:
sdk.broadcast.test() const draft = await sdk.broadcast.create({
store_id: storeId,
id: crypto.randomUUID(),
key: "november-news",
audience: {
include: [{ type: "customer_group", customer_group_id: newsletter.id }],
exclude: [],
conditions: [{ type: "ordered_within", days: 365 }],
},
sending_address_id: news.id,
content: {
en: {
subject: "New this month",
preheader: "Three new arrivals and an open evening",
body: `<p>Hi,</p>
<p>{{media "6f4c2f0e-1d2b-4c1a-9a51-6a2a3d0f9b10"}}</p>
<p><a href="{{unsubscribe_url}}">Unsubscribe</a></p>`,
},
},
});
const scheduled = await sdk.broadcast.schedule({
store_id: storeId,
id: draft.id,
expected_updated_at: draft.updated_at,
send_at: Date.UTC(2026, 10, 1, 8, 0),
});Parameters
| Name | Type | Description |
|---|---|---|
id required | string | The broadcast id the app picks (UUID v4). |
key required | string | The label staff see. |
audience required | BroadcastAudience | The groups and offerings to include and exclude, and the conditions every recipient matches. |
sending_address_id required | string | An active sending address of the store; replies go to its reply-to address. |
content required | Record<string, EmailContent> | Subject, preheader and Handlebars body per store language. |
Rules
- Audience.
includenames at least one group or offering; an entry is never both included and excluded, and each kind of condition appears at most once. A recipient is a person member of an included group or offering who is in no excluded one and matches every condition:ordered_product(an order with any variant of that product),ordered_within(the newest order is at most that many days old) andnot_ordered_within(no order in that many days). To leave one person out, put them in a group and exclude it. - Handlebars, checked when saved. Each body must compile and use only known fields: the
customer’s email (
customer.email) and blocks (customer.profile.<key>,customer.blocks.<key>), the store (store.name), the member’s group and its offering (customer_group_member.customer_group.keyand.blocks.<key>,customer_group_member.customer_group_offering.keyand.blocks.<key>),unsubscribe_url, and{{media "<id>"}}for a picture from the store’s media. Every body must contain{{unsubscribe_url}}. - Languages. A language added to
contentmust be one of the store’s languages. A person gets the language of the order of their newest qualifying member. An address with no text in that language gets one email recordednot_sentwithno_text_in_language, and it still counts: there is no fallback. Text that can’t be built from a person’s data, such as a subject that comes out empty, records that person’s emailnot_sentwithrender_failed: <reason>, and it counts too. - Who gets it: person members who joined with an order, joined before sending began and still count when their batch is made, at an email the customer proved with a sign-in code, never at an address the store suppressed (unsubscribed, blocked, bounced or complained). Members the team assigned without an order, and company or location members, get none. A member who cancelled still gets broadcasts for the period it paid for.
- Sending. Creating or editing a broadcast needs an active address with a sending side, and
scheduling a future
send_atand a ready sender. Atsend_at, or at once withsend, the broadcast becomessending; a scheduled broadcast whose sender isn’t ready or whose store isn’t active then goes back todraftinstead. Arky makes onebroadcast_emailoutgoing email per recipient, unique per broadcast and address, so a rerun makes no second email. Its Resend send key isbroadcast:{id}:{hex}, the broadcast and the SHA-256 of the address, so a batch repeated after a restore sends nothing twice. When the walk ends the broadcast issent, withrecipient_count: the emails made, not the deliveries. - Editing. Everything changes only while
draft. Ascheduledbroadcast goes back todraftwithunschedule. Every action carries theexpected_updated_atit read. There is no stop: pausing the store’s email holds the queued emails. - Unsubscribe. Every broadcast email has the one-click
List-Unsubscribeheaders and the body’s{{unsubscribe_url}}, one signed link naming the store, the address and the broadcast. Opening the link only shows its confirmation; the one-click post records the store’s unsubscribe for the address, so a link scanner unsubscribes nobody. Arky checks it right before each send, so the address’s waiting broadcast emails endnot_sent. It never ends a membership, paid or free. Signing up for a free group again, orresubscribefrom the account, lifts it. - Replies go to the sending address’s reply-to address, the store’s own inbox. A store that lets that address receive gets the replies as support conversations.
- Preview and test fill the body with a sample.
testtakes theoutgoing_email_idthe app picked for the click and sends to the person clicking. - Deleting. A
draftorscheduledbroadcast is deleted in one write. Once sending starts it stays, with its count.
await sdk.broadcast.test({
store_id: storeId,
id: draft.id,
outgoing_email_id: crypto.randomUUID(),
language: "en",
});
const sent = await sdk.broadcast.find({ store_id: storeId, status: "sent", limit: 20 });
const emails = await sdk.notification.outgoingEmail.find({ store_id: storeId, broadcast_id: draft.id }); Note
Opens and clicks aren’t recorded on the broadcast. A broadcast is one email; a welcome series is
a later feature. Broadcasts need the marketing permission, and the plan’s broadcasts limit
counts the broadcasts not yet sent.
See the Newsletters guide for the sign-up side.