Hogsend
Recipes

Marketing campaigns

Broadcast a template to an audience with hs.campaigns.send({ list, template, props }). Define a list, subscribe contacts, and broadcast marketing/product-update — 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.

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.

On this page