Hogsend is brand new.Try it
Hogsend
Recipes

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 emailemail_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 createHogsendClientcreateWorker 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 sendAt schedules the send; editing the file (audience, template, props, sendAt, …) and redeploying updates the still-scheduled row, and moving sendAt re-schedules.
  • A sendAt staler than the grace window (default 1h, env CAMPAIGN_DEFINE_GRACE_MS) at the first deploy is stamped expired with 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. A canceled campaign 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:

listbucket
What it isA named subscription a contact opts in/out ofA real-time segment defined by criteria over contact state
MembershipExplicit — the contact chose to be on itComputed — the contact matches the rules right now
RecipientsEvery subscribed memberEvery 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, like product-updates) — a contact is a member only if categories["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 unless categories[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 a queued campaign row and enqueues a Hatchet send-campaign task. If the enqueue transiently fails, the row is already committed and a reaper cron re-enqueues it — the request still returns 202 rather than 500-ing after a committed row.
  • Idempotent. Pass an idempotencyKey (or the Idempotency-Key header, which wins). A retried send() 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.