Back to Arky

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

  1. Audience. include names 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) and not_ordered_within (no order in that many days). To leave one person out, put them in a group and exclude it.
  2. 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.key and .blocks.<key>, customer_group_member.customer_group_offering.key and .blocks.<key>), unsubscribe_url, and {{media "<id>"}} for a picture from the store’s media. Every body must contain {{unsubscribe_url}}.
  3. Languages. A language added to content must 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 recorded not_sent with no_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 email not_sent with render_failed: <reason>, and it counts too.
  4. 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.
  5. Sending. Creating or editing a broadcast needs an active address with a sending side, and scheduling a future send_at and a ready sender. At send_at, or at once with send, the broadcast becomes sending; a scheduled broadcast whose sender isn’t ready or whose store isn’t active then goes back to draft instead. Arky makes one broadcast_email outgoing email per recipient, unique per broadcast and address, so a rerun makes no second email. Its Resend send key is broadcast:{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 is sent, with recipient_count: the emails made, not the deliveries.
  6. Editing. Everything changes only while draft. A scheduled broadcast goes back to draft with unschedule. Every action carries the expected_updated_at it read. There is no stop: pausing the store’s email holds the queued emails.
  7. Unsubscribe. Every broadcast email has the one-click List-Unsubscribe headers 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 end not_sent. It never ends a membership, paid or free. Signing up for a free group again, or resubscribe from the account, lifts it.
  8. 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.
  9. Preview and test fill the body with a sample. test takes the outgoing_email_id the app picked for the click and sends to the person clicking.
  10. Deleting. A draft or scheduled broadcast 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.