Hogsend is brand new.Try it
Hogsend
Data API

Campaigns

POST/GET /v1/campaigns — broadcast one template to a list or bucket, schedule it for a future instant, cancel it, and list campaigns. Plus defineCampaign() for committing a broadcast to your repo.

A campaign is a one-time broadcast of a single template to a whole audience — every subscribed member of a list, or every active member of a bucket. The send runs as a durable Hatchet task in the worker, so POST /v1/campaigns returns an enqueue (or schedule) ack, not the finished send. Each recipient flows through the same tracked mailer as every other send, so unsubscribed/suppressed contacts are skipped and link-click + open tracking apply automatically.

Requires a bearer key with the ingest scope.

This reference covers the wire; the Campaigns guide covers authoring — when to commit a campaign versus create it at runtime, audience semantics, and the deploy lifecycle.

POST /v1/campaigns

Validates the template and audience, inserts the campaign row, and either enqueues the durable send-campaign task immediately or — when sendAt is supplied — schedules it for that instant.

Request

Exactly one of list or bucket is required.

{
  "name": "July launch",
  "list": "newsletter",
  "bucket": "power-users",
  "template": "marketing/product-update",
  "props": { "headline": "Saved views are here" },
  "from": "team@example.com",
  "subject": "What's new",
  "sendAt": "2026-07-15T16:00:00Z",
  "idempotencyKey": "july-launch"
}
FieldTypeRequiredDescription
liststringone of list/bucketBroadcast to every subscribed member of this list
bucketstringone of list/bucketBroadcast to every active member of this bucket
templatestringYesRegistry key. Validated server-side against the wired registry
propsRecord<string, unknown>NoTemplate props (the template's variables)
namestringNoHuman label for the campaign
fromstringNoSender override (defaults to the template, then the mailer's defaultFrom)
subjectstringNoSubject override (defaults to the template)
sendAtstringNoISO 8601 future instant. Present → the campaign is scheduled and delivered then; absent → enqueued immediately
idempotencyKeystringNoDedup key (or the Idempotency-Key header, which wins)

The audience is exactly one of list or bucket — passing both, or neither, is a 400. A list audience follows the list's opt-in/opt-out polarity (defaultOptIn); see Lists. A bucket audience is every contact matching the bucket's criteria at send time.

sendAt scheduling

  • Absent — the row is created queued and the durable send task is enqueued immediately.
  • A future instant — the row is created scheduled and delivered at that instant (a Hatchet scheduled run; the reaper's due-scheduled sweep is the backstop if the scheduled run fails to fire).
  • More than 60s in the past400. A stale timestamp almost certainly did not mean "blast now".
  • Within 60s in the past — degrades to an immediate send.

Response 202

{ "campaignId": "…", "status": "queued", "scheduledAt": null }
FieldTypeDescription
campaignIdstringThe campaigns row id — poll GET /v1/campaigns/{id} for counts
statusstringqueued for an immediate send, scheduled when sendAt was supplied
scheduledAtstring | nullThe send instant for a scheduled campaign; null for an immediate send

The 202 is an enqueue ack — the actual sends run in the worker. status starts queued/scheduled and the worker advances it as it broadcasts.

Idempotency

Pass an idempotencyKey (or the Idempotency-Key header, which wins over the body field). A retried create 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 audience.

GET /v1/campaigns

Lists campaigns, newest first.

curl "http://localhost:3002/v1/campaigns?status=scheduled,sending&limit=50&offset=0" \
  -H "Authorization: Bearer $HOGSEND_DATA_KEY"
Query paramTypeDefaultDescription
statusstringComma-separated status filter, e.g. scheduled,sending
limitnumber50Page size, 1–200
offsetnumber0Page offset

Response 200

{ "campaigns": [ /* … newest first */ ], "hasMore": true }

GET /v1/campaigns/{id}

Returns a single campaign with its live send counts.

curl http://localhost:3002/v1/campaigns/$CAMPAIGN_ID \
  -H "Authorization: Bearer $HOGSEND_DATA_KEY"

Unknown id → 404. See The campaign object for the shape.

POST /v1/campaigns/{id}/cancel

Cancels a scheduled, queued, or sending campaign and returns it.

curl -X POST http://localhost:3002/v1/campaigns/$CAMPAIGN_ID/cancel \
  -H "Authorization: Bearer $HOGSEND_DATA_KEY"

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) returns 409.

Statuses

queued    ─┐  (immediate send)
scheduled ─┴→ sending → sent
StatusMeaning
scheduledCreated with a future sendAt; waiting to be delivered
queuedEnqueued for immediate send
sendingThe worker is broadcasting
sentTerminal — the broadcast completed
failedThe reaper gave up re-driving a stuck send; re-runnable
canceledTerminal — canceled via the cancel endpoint
expiredTerminal — a code-defined campaign whose sendAt was already stale at first deploy (see defineCampaign())

The campaign object

Returned by GET /v1/campaigns/{id} and in the campaigns array of the list endpoint.

FieldTypeDescription
idstringCampaign id
namestringHuman label
statusstringOne of the statuses above
audienceKind"list" | "bucket"Which audience model
audienceIdstringThe list or bucket id
templateKeystringThe template being broadcast
totalRecipientsnumberAudience size resolved at send time
sentCountnumberRecipients dispatched
skippedCountnumberRecipients skipped (unsubscribed/suppressed)
failedCountnumberRecipients whose send failed
scheduledAtstring | nullThe send instant for a scheduled campaign
canceledAtstring | nullWhen it was canceled
startedAtstring | nullWhen the broadcast began
completedAtstring | nullWhen the broadcast finished
createdAtstringWhen the row was created

Delivery guarantees

The broadcast runs as a durable Hatchet task, so these hold without extra wiring:

  • Idempotent per recipient. Each recipient send carries the idempotency key campaign:<campaignId>:<email>, so a retry never double-sends to that address.
  • Self-healing. A reaper cron re-drives stuck sends and promotes due scheduled campaigns if the punctual scheduled run failed to fire.
  • Preference-checked. Suppressed and globally-unsubscribed contacts are excluded at audience resolution and re-checked per send. They are counted in skippedCount, not emailed.
  • Tracked. Links and opens are rewritten and re-ingested as email.link_clicked / email.opened events, exactly as for transactional and journey sends.

Errors

StatusMeaning
400Both or neither of list/bucket, unknown template, or sendAt more than 60s in the past
401Missing/invalid key
403Key lacks the ingest scope
404Unknown list, bucket, or campaign id
409Cancel requested on a campaign already in a terminal status

Code-defined campaigns with defineCampaign()

A campaign can be committed to your repo instead of created over HTTP. defineCampaign() mirrors defineJourney() / defineList(): write a file, deploy, and the worker's boot reconciler schedules it. 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: "newsletter" },  // 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 (so the catalog is known); createWorker defaults to the container's, so it takes no campaigns of its own:

const container = createHogsendClient({ journeys, campaigns });
const worker = createWorker({ container, journeys });

A defined campaign is not a new runtime. The reconciler upserts it into the same campaigns row and durable send-campaign task the data plane uses, so scheduling, cancel, counts, and Studio visibility are identical regardless of how the campaign was created.

defineCampaign() options

FieldTypeRequiredDescription
idstringYesStable slug. Must match /^[a-z0-9_-]+$/i. The retirement key
namestringNoHuman label. Defaults to id
audience{ list } | { bucket }YesExactly one of list or bucket
templateTemplateNameYesType-checked against your registry
propsRecord<string, unknown>NoTemplate props
subjectstringNoSubject override
fromstringNoSender override
sendAtDate | stringYesThe send instant (ISO string or Date)
enabledbooleanNoDefaults to true

defineCampaign() validates at definition time: the id shape, exactly one of list/bucket, and that sendAt parses to a real instant. Whether sendAt is still in the future is deliberately not checked here — that is a deploy-time question the reconciler answers. The audience and template are validated against the registries at boot; a broken definition is skipped with an error log rather than crashing the worker.

What the boot reconciler does

At worker boot a reconciler upserts each definition into a campaigns row keyed campaign-def:<id>:

At reconcileOutcome
No row yet, sendAt in the futureCreated scheduled and delivered at that instant
No row yet, sendAt due within the grace window (default 1h, env CAMPAIGN_DEFINE_GRACE_MS)Sent on boot — covers a deploy that landed minutes late
No row yet, sendAt staler than the grace window at first deployCreated expired with a warning logged — a late deploy never fires a surprise blast
Row still scheduled, file editedMutable fields (name, audience, template, props, subject, from, sendAt) synced; moving sendAt re-schedules
Row already sentNo-op — the campaign is retired. Deleting the file keeps the history row
Row canceledNo-op — a canceled campaign is never resurrected by a redeploy

A defined campaign expires rather than fires when its sendAt is already stale at the first deploy. To recover, bump sendAt (or delete the file) and redeploy — there is no way for a campaign committed with last week's date to blast on deploy.

One campaign is one send to the audience as resolved at send time — members added to the list or bucket after the send do not receive it.

SDK

@hogsend/client wraps every endpoint. template/props are type-checked against your registry.

const hs = new Hogsend({ baseUrl, apiKey });

await hs.campaigns.send({
  list: "newsletter",
  template: "marketing/product-update",
  props: { headline: "Saved views are here" },
  sendAt: "2026-07-15T16:00:00Z",   // omit to send now
  idempotencyKey: "july-launch",
});                                  // → { campaignId, status, scheduledAt }

await hs.campaigns.list({ status: ["scheduled"] }); // → { campaigns, hasMore }
await hs.campaigns.get(campaignId);                 // → Campaign
await hs.campaigns.cancel(campaignId);              // → Campaign

The Marketing campaigns recipe walks the full define-list → subscribe → broadcast workflow.

CLI

@hogsend/cli wraps the same routes for one-off operator use:

hogsend campaigns send --list newsletter --template launch --at 2026-07-15T16:00:00Z
hogsend campaigns list --status scheduled
hogsend campaigns status cmp_123
hogsend campaigns cancel cmp_123

Studio

The Studio Campaigns view lists campaigns with their status and send counts, and can cancel a scheduled, queued, or sending one.