Marketing campaigns
Broadcast a template to an audience with hs.campaigns.send({ list, template, props }). Define a list, subscribe contacts, broadcast, schedule with sendAt, cancel, and commit a broadcast to your repo with defineCampaign() — list vs bucket audiences, polarity, and the durable/idempotent/preference-checked send.
A marketing campaign is a one-time broadcast of a single template to a whole audience. You send one with:
await hs.campaigns.send({ list: "product-updates", template: "marketing/product-update", props });It queues a durable broadcast that sends the template to every subscribed member of the list, asynchronously, in the worker. Exactly one of list or bucket is the audience, and the whole audience is code-defined:
await hs.campaigns.send({
list: "product-updates",
template: "marketing/product-update",
props: { version: "2.4", headline: "Saved views are here" },
});
// → { campaignId, status: "queued" }hs.campaigns.send maps to POST /v1/campaigns. The 202 you get back is an enqueue ack — the actual sends run in the worker. Poll hs.campaigns.get(campaignId) for live counts (totalRecipients, sentCount, skippedCount, failedCount).
1. Define the list
Lists are code-defined with defineList(), authored in src/lists/ next to your journeys and buckets. Membership rides the existing email_preferences.categories store — no new table.
// src/lists/index.ts
import { defineList } from "@hogsend/engine";
export const productUpdates = defineList({
id: "product-updates",
name: "Product updates",
description: "Announcements about new features.",
defaultOptIn: false, // opt-in: a contact is a member only if they explicitly joined
});
export const lists = [productUpdates];Thread the array into createHogsendClient — in both the api entry (src/index.ts) and the worker entry (src/worker.ts), since createHogsendClient runs in each process. Lists are wired at the container level only; createWorker reads membership through the container reference, so it takes no lists of its own:
const container = createHogsendClient({ journeys, lists });
const worker = createWorker({ container, journeys });See the Lists reference for the full defineList() option table and the reserved-id rules.
2. Subscribe contacts
Flip a contact's membership on — a one-liner, or part of an upsert/events.send call via the lists bag.
// explicit subscribe
await hs.lists.subscribe({ list: "product-updates", email: "ada@example.com" });
// or fold it into a contact upsert
await hs.contacts.upsert({
email: "ada@example.com",
properties: { plan: "pro" },
lists: { "product-updates": true },
});
// unsubscribe is the mirror
await hs.lists.unsubscribe({ list: "product-updates", userId: "user_123" });Subscribe/unsubscribe resolve (or create) the contact first, then flip the membership key. List writes need a resolvable email — email_preferences is keyed on the email column — so a userId-only contact with no email on record returns 400.
3. Broadcast
const { campaignId } = await hs.campaigns.send({
list: "product-updates",
template: "marketing/product-update",
props: { version: "2.4", headline: "Saved views are here" },
subject: "What's new in 2.4", // optional override
idempotencyKey: "product-update-2.4", // safe to retry — see below
});
// later — poll for progress
const campaign = await hs.campaigns.get(campaignId);
// { status, totalRecipients, sentCount, skippedCount, failedCount, … }template/props are type-checked against your registry exactly like transactional sends. marketing/product-update is the canonical marketing template key in the docs taxonomy.
Schedule for later
Pass sendAt (an ISO 8601 future instant) to deliver the broadcast at that time instead of now. The campaign is created scheduled and delivered by a Hatchet scheduled run, with the reaper as a backstop if the punctual run fails to fire.
const { campaignId, status, scheduledAt } = await hs.campaigns.send({
list: "product-updates",
template: "marketing/product-update",
props: { version: "2.4", headline: "Saved views are here" },
sendAt: "2026-07-15T16:00:00Z",
});
// → { campaignId, status: "scheduled", scheduledAt: "2026-07-15T16:00:00Z" }A sendAt more than 60s in the past is a 400; one within 60s past degrades to an immediate send. Omit sendAt to enqueue immediately (status: "queued", scheduledAt: null).
Cancel
Cancel a scheduled, queued, or sending campaign:
await hs.campaigns.cancel(campaignId); // → the campaign, now "canceled"A mid-send cancel stops the blast at the next chunk boundary (chunks of 100): recipients not yet dispatched are spared; already-dispatched emails are not recalled. A campaign already in a terminal status (sent, canceled, expired) rejects with a 409.
List
const { campaigns, hasMore } = await hs.campaigns.list({ status: ["scheduled"] });Newest first; filter by status and page with limit (1–200, default 50) / offset.
Commit a campaign to your repo with defineCampaign()
Instead of calling send() over HTTP, you can commit a broadcast to your repo. defineCampaign() mirrors defineList() / defineJourney(): write a file, deploy, and the worker schedules it at boot. Once sent it is retired — redeploys no-op.
// src/campaigns/index.ts
import { defineCampaign } from "@hogsend/engine";
import { Templates } from "../journeys/constants/index.js";
export const julyLaunch = defineCampaign({
id: "july-2026-launch", // stable slug; the retirement key
name: "July launch announcement",
audience: { list: "product-updates" }, // or { bucket: "power-users" }
template: Templates.ProductUpdate, // type-checked against your registry
props: { headline: "Saved views are here" },
sendAt: "2026-07-15T16:00:00Z",
});
export const campaigns = [julyLaunch];Thread the array into createHogsendClient — createWorker reads the container's, so it takes no campaigns of its own:
const container = createHogsendClient({ journeys, lists, campaigns });
const worker = createWorker({ container, journeys });The reconciler upserts each definition into the same campaigns row and durable send task the data plane uses, so scheduling, cancel, counts, and Studio visibility are identical to an API-created campaign. The workflow is git-ops:
- A future
sendAtschedules the send; editing the file (audience, template, props,sendAt, …) and redeploying updates the still-scheduledrow, and movingsendAtre-schedules. - A
sendAtstaler than the grace window (default 1h, envCAMPAIGN_DEFINE_GRACE_MS) at the first deploy is stampedexpiredwith a warning — a late deploy never fires a surprise blast. - Once
sent, the campaign is retired: redeploys no-op and deleting the file keeps the history row. Acanceledcampaign is never resurrected by a redeploy.
See the Campaigns reference for the full defineCampaign() option table and the reconciler's decision table.
List vs bucket audience — and polarity
A campaign's audience is exactly one of list or bucket (passing both, or neither, is a 400). The difference is the audience model:
list | bucket | |
|---|---|---|
| What it is | A named subscription a contact opts in/out of | A real-time segment defined by criteria over contact state |
| Membership | Explicit — the contact chose to be on it | Computed — the contact matches the rules right now |
| Recipients | Every subscribed member | Every active member |
// broadcast to a code-defined list (opt-in subscription)
await hs.campaigns.send({ list: "product-updates", template: "marketing/product-update", props });
// broadcast to a computed segment instead
await hs.campaigns.send({ bucket: "power-users", template: "marketing/product-update", props });Polarity: what "subscribed" means
A list's defaultOptIn decides what an absent membership key means — the heart of who counts as a recipient:
defaultOptIn: false(opt-in, likeproduct-updates) — a contact is a member only ifcategories["product-updates"] === true. They had to explicitly join. New contacts are not broadcast to until they subscribe.defaultOptIn: true(opt-out) — a contact is a member unlesscategories[id] === false. Membership is the default; they have to explicitly leave.
This single rule (ListRegistry.isSubscribed) is the one source of truth shared by the broadcast's recipient resolution and the preference center, so a campaign and the preference UI always agree. See Lists for the full polarity table.
Durable, idempotent, preference-checked
Three guarantees the broadcast gives you out of the box:
- Durable.
send()inserts aqueuedcampaign row and enqueues a Hatchetsend-campaigntask. If the enqueue transiently fails, the row is already committed and a reaper cron re-enqueues it — the request still returns202rather than 500-ing after a committed row. - Idempotent. Pass an
idempotencyKey(or theIdempotency-Keyheader, which wins). A retriedsend()with the same key resolves to the existing campaign instead of spawning a second broadcast — so a network retry never double-sends to your whole list. - Preference-checked. Each recipient flows through the same tracked mailer as every other send, so unsubscribed/suppressed contacts are skipped (counted in
skippedCount, not emailed), and links + opens are tracked and loop back as events — just like transactional and lifecycle sends.
Response
const { campaignId, status } = await hs.campaigns.send({ /* … */ });
// status: "queued" | "sending" | "sent" | "failed"The send is asynchronous: status starts queued and the worker advances it as it broadcasts. Use hs.campaigns.get(campaignId) to watch sentCount / skippedCount / failedCount climb to completion.
Lifecycle journeys
Build behaviour-driven sequences with defineJourney(). An onboarding series, a bucket-triggered trial-expiring journey, and a win-back journey — durable sleeps, branching, and tracked sends with the lifecycle/* templates.
Events & contacts
Identify and track with hs.contacts.upsert and hs.events.send. The identify/track patterns, and the contactProperties vs eventProperties split that makes them work.