Hogsend
Concepts

How It Works

The mental model behind Hogsend — events in, journeys and emails out, engagement back — and how the scaffold maps that model to the files you edit, so every recipe clicks into place.

Hogsend in one mental model

Hogsend is the lifecycle layer between the events your users generate and the email you send. Email goes out through a swappable provider — Resend by default. Hold three ideas and everything else follows:

  1. Events flow in. A PostHog webhook, a call from your own app via @hogsend/client / POST /v1/events, or any defineWebhookSource() — they all normalize to one kind of thing: an event for a user, through ingestEvent().
  2. Your code reacts. Journeys send lifecycle email, buckets group users in real time, lists hold preference categories, campaigns broadcast to an audience — all written as TypeScript, not drawn on a canvas.
  3. Engagement flows back. Opens, clicks, sends, journey completions, and bucket transitions fan back out to PostHog (and Segment, Slack, your warehouse, a CRM) via code-defined destinations, so email lives in the same analytics as everything else.

PostHog Lifecycle Email Flow — events flow in, lifecycle emails go out, engagement data flows back

That's the loop. The rest of this page is the toolkit that lives inside it, and how the scaffold hands it to you.

Your toolkit: a handful of primitives

Everything in the recipe book is a combination of a few code-first primitives. Learn these once and a recipe becomes "which of these, in what order":

PrimitiveWhat it doesWhere it livesGuide
Eventsthe universal trigger — everything starts hereingested (no file)Events
JourneysdefineJourney() — durable TypeScript that reacts to an event: send, wait, branchsrc/journeys/Journeys
EmailsReact Email templates + sendEmail() — what journeys sendsrc/emails/Email
BucketsdefineBucket() — real-time segments that update as events arrivesrc/buckets/Buckets
ListsdefineList() — subscription categories for the preference centersrc/lists/Email Lists
DestinationsdefineDestination() — fan the event stream out to PostHog/Segment/Slacksrc/destinations/Destinations
Webhook sourcesdefineWebhookSource() — turn any provider's webhook into an eventsrc/webhook-sources/Webhook Sources

And from your own app code, the @hogsend/client SDK pushes events, upserts contacts, and sends transactional email over the data plane.

Engine vs. content — why it's all yours

Hogsend ships as a framework you install, not a repo you fork:

  • The engine@hogsend/engine (plus the other @hogsend/* packages) owns the ingestion pipeline, the durable journey runtime, email delivery + tracking, the HTTP API, and the worker loop. You upgrade it with pnpm up; you never edit it.
  • Your content — the primitives in the table above. They live in your repo and you inject them into the engine. The engine never imports your content.

The folders in that table are your content: you write the primitives, collect each kind into an array, and hand them to the engine's factories. That boundary is what makes upgrades a pnpm up instead of a git merge — see Philosophy and Upgrading & Customizing.

The scaffolding experience

One command gives you the whole thing — a thin app pinned to the engine, every primitive folder stubbed with a working example, and a local stack:

pnpm dlx create-hogsend@latest my-app
cd my-app
pnpm bootstrap     # Docker + .env + auto-mints the Hatchet token + migrate + data-plane key
pnpm dev           # HTTP API on http://localhost:3002
pnpm worker:dev    # Hatchet worker (second terminal)

pnpm bootstrap is idempotent and safe to re-run, and needs no credentials for local dev: it starts TimescaleDB + Redis + Hatchet-Lite, generates a BETTER_AUTH_SECRET, auto-mints the HATCHET_CLIENT_TOKEN, runs both migration tracks, mints an ingest-scoped HOGSEND_API_KEY, and offers to create your first Studio admin. Set a real RESEND_API_KEY (starts re_) before sending live email with the default provider. The full walkthrough is in Installation.

What you get maps one-to-one onto the toolkit:

my-app/src/
├─ journeys/        # defineJourney()      — your lifecycle sequences
├─ emails/          # React Email templates + registry
├─ buckets/         # defineBucket()       — real-time segments
├─ lists/           # defineList()         — subscription categories
├─ destinations/    # defineDestination()  — outbound fan-out
├─ webhook-sources/ # defineWebhookSource() — inbound events (PostHog ships here)
├─ workflows/       # extra Hatchet tasks
├─ index.ts         # HTTP entry  — wires content into the engine
└─ worker.ts        # worker entry — runs the durable tasks

The two entry files are the only "wiring": you collect each primitive into an array and inject it.

// src/index.ts — the HTTP API
import { createApp, createHogsendClient } from "@hogsend/engine";
import { journeys } from "./journeys/index.js";
import { buckets } from "./buckets/index.js";
import { lists } from "./lists/index.js";
import { destinations } from "./destinations/index.js";
import { templates } from "./emails/index.js";
import { webhookSources } from "./webhook-sources/index.js";

const client = createHogsendClient({
  journeys,
  buckets,
  lists,
  destinations,
  email: { templates },
});
const app = createApp(client, { webhookSources });
// src/worker.ts — the durable task runner
import { createHogsendClient, createWorker } from "@hogsend/engine";
// …same content imports as above, plus:
import { extraWorkflows } from "./workflows/index.js";

const client = createHogsendClient({
  journeys,
  buckets,
  lists,
  destinations,
  email: { templates },
});
const worker = createWorker({
  container: client,
  journeys,
  buckets,
  extraWorkflows,
});
await worker.start();

Adding a journey is the whole authoring loop: write the file, add it to src/journeys/index.ts, and the engine picks it up — same pattern for every other primitive.

What happens when an event arrives

To make the model concrete, follow one event. A user signs up, PostHog captures user_signed_up, and its webhook hits POST /v1/webhooks/posthog:

  1. Ingest — your PostHog webhook source transforms the payload into an event (distinct_iduserId, person.properties.emailuserEmail) and hands it to the engine's ingestEvent().
  2. Fan out, concurrently — the event is stored in user_events, routed to every journey and bucket whose trigger matches, checked against the exit conditions of the user's active journeys, and the contact is upserted.
  3. A journey enrolls — if a journey triggers on user_signed_up, the engine runs its enrollment guards (enabled, trigger.where, entry limit, subscription, not-already-active), then starts a durable run.
  4. It runs — send a welcome email, ctx.sleep({ duration: days(2) }) (survives deploys), check whether the user activated, branch. Every step is persisted, so you can see exactly where each user is.
  5. Email goes outsendEmail() renders the React template to HTML, rewrites links + injects the open pixel for first-party tracking (the single source of truth), signs an unsubscribe URL, then hands the HTML to your provider (Resend by default; the provider is a swappable wire) with retries.
  6. Engagement flows back — opens and clicks are recorded and pushed back through the pipeline so journeys can react (email.opened / email.link_clicked), and fanned out to your destinations — PostHog included, where the outbound catalog calls them email.opened / email.clicked.

Same event in, a different lifecycle out — and the whole thing is your TypeScript.

From the model to the recipe book

Once the toolkit clicks, the recipes are just combinations of these pieces:

You want to…CombineRecipe
Send a one-off transactional email (receipt, reset)an event + an email template (or hs.emails.send)Transactional Emails
Run a multi-step lifecycle sequencea journey + emails + ctx.sleep / ctx.waitForEventLifecycle Journeys
Broadcast to an audiencea bucket or list + a campaignMarketing Campaigns
Get events and contacts ina webhook source or hs.events.sendEvents and Contacts

Pick the outcome, reach for the primitives, write the code.

What the engine handles for you

Because you only write content, the engine brings the batteries — all consumed through the factories, none of it forked: durable execution (sleeps survive deploys), enrollment guards + exit conditions, the condition engine shared across journeys and buckets, React Email rendering + open/click/bounce tracking + automatic suppression + one-click unsubscribe, contact management, the durable outbound webhook spine (retries + dead-letter queue), a public data-plane API, and an admin API with metrics, alerts, and audit logs.

Next steps

On this page