Hogsend is brand new.Try it
Hogsend
Latest release: v0.44.0

Changelog

Every release of the engine, CLI, Studio, and providers. Upgrading is pnpm up "@hogsend/*" — never a fork merge.

v0.44.0

Revenue, deal funnels, and multi-model attribution

  • value and currency are first-class columns on every event now — a real money column, not a property convention. Rollups are per-currency and never cross-summed; malformed money is dropped. @hogsend/js auto-captures the ad click on landing (fbclid, gclid and nine more, or any utm_*) as a campaign.arrived touchpoint, persisted last-touch.
  • New defineFunnel — event-native deal funnels. Ordered stages advance on your own events, with money milestones: milestone: “quoted” mints deal.quoted and milestone: “won” mints deal.sold — stable names across any ladder, once per deal. A crmPipeline binding composes a CRM leg; no CRM required, and one deployment runs many funnels.
  • New defineConversion— declares which events count, what they’re worth (event / fixed / property), and where they dispatch, behind a forged-value guard that rejects browser-tier values by default. A zero-config revenue conversion is auto-seeded. @hogsend/plugin-meta-capi is the first native destination — hashed identifiers, the real stored click, and a deterministic event_id across retries.
  • New @hogsend/attributionengine. Every conversion writes a credit ledger under all eight models at fire time (first, last, last-non-direct, linear, time-decay, position-U, position-W, blended), so switching models in reporting is instant and historical — never re-derived. Studio’s Impact tab keeps three numbers separate: Attributed (fractional, sums to the real total), Influenced (reach), and Incremental (holdout-backed lift). hogsend attribution backfill recomputes history.

Upgrade: pnpm dlx hogsend upgrade (deps + vendored skills). Additive — attribution accrues automatically once you capture arrivals and fire valued events; there is no config key to set.

v0.43.0

First-class SMS channel

  • SMS mirrors the email architecture end to end. A provider-neutral SmsProvider contract (defineSmsProvider), a new @hogsend/sms package (templates authored as React, rendered to plain text, with a GSM-7/UCS-2 segment counter), and @hogsend/plugin-twilio as the reference wire. Opt-in: with no provider configured the SMS service is an inert stub, so existing deploys are unaffected.
  • Explicit consent by default (TCPA). The sms channel is defaultOptIn: false — a marketing text needs an explicit grant or an inbound START, else it fails closed. Transactional sends bypass only the consent and topic gates, never the phone STOP list. A genuine grant emits the new contact.subscribed event; full STOP/START/HELP handling ships in the box.
  • First-party SMS link tracking, on by default. Bare URLs become /s/<code> short links riding the same click spine as email — per-hit sms.clicked, first-touch clicked_at, and an sms.link_clicked bus event your journeys can wait on.

Upgrade: pnpm dlx hogsend upgrade. SMS is entirely opt-in — add a Twilio provider and a sender to turn it on; leave them out and nothing changes.

v0.42.0

Journey Blueprints — hardened, and promote-to-code

  • New hogsend blueprints promote turns a JSON Journey Blueprint into a real code-first defineJourney file on a fresh branch — faithful to the interpreter, with ctx.exit(), idempotency labels, and frozen non-deterministic decisions. It never commits or pushes.
  • New ctx.exit(reason?) journey primitive — terminate an enrollment as exited (no journey:completed or journey:failed). The blueprint interpreter and promoted code share the one mechanism.
  • Save-path hardening across the interpreter, save path, and Studio: reserved engine namespaces are rejected at save time, an entryPeriodmust be a positive duration, and enable / update / promote races are closed with guarded conditional writes and an advisory lock so a graph edit can’t desync a suspended run’s replay.

Upgrade: pnpm dlx hogsend upgrade. Additive; the new save-time validation only rejects blueprints that were already unsafe.

v0.41.0

Channel preferences

  • Delivery channels are now opt-out lists. The engine auto-registers a kind: “channel” list for the in-app feed (in_app, always) and one per member-directed connector (telegram always in the dogfood, discord when configured). They live in the same email_preferences.categories namespace and are managed with the usual POST /v1/lists/:id/(un)subscribe. A defineList id may not collide with a channel id, and in_app is reserved — both throw at boot.
  • Member-directed connector actions are preference-gated automatically. Discord dmMember and Telegram dm / sendMessage skip with a typed ConnectorActionSkipped (guarded by isConnectorActionSkipped) when the resolved contact has globally unsubscribed or opted out of that connector’s channel. Ops actions (roles, broadcasts, channel messages) are never gated, and a send with no resolvable contact proceeds. The skip verdict is replay-stable. The in-app feed now enforces in_app the same way, through an aggregated multi-row preference read.
  • defineJourney meta gains an optional category that stamps every one of the journey’s sendEmailsends (overriding the template’s category, like the built-in journey default). It is boot-validated fail-closed: unknown, channel-list, or excluded-opt-in categories throw; excluded opt-out warns. Campaign audiences reject channel lists too.
  • The SDK and Studio caught up. GET /v1/lists items carry kind; a new POST /v1/lists/preferences writes the account-wide unsubscribedAll master toggle behind the same identity gate as list writes. @hogsend/js adds ListSummary.kind, preferences().setUnsubscribedAll(), and the exported ALL_EMAILS_CATEGORY (“$all”) sentinel; @hogsend/react’s <PreferenceCenter> auto-sections into Channels (with a synthetic Email master row) and Topics, with new layout / emailToggle / sectionLabelsprops. Studio’s contact drawer gains per-channel and per-topic toggles.

Upgrade: pnpm up "@hogsend/*". Additive — an older engine emits no kind, so the preference center renders flat exactly as before. Refresh the vendored skills with pnpm dlx hogsend skills add --force.

v0.39.0

Studio journey flow, typed template keys, and boot-time config guards

  • Studio can now render a journey’s control flow as a graph. A typed node/edge journey-graph IR lands in @hogsend/core. The engine ships an AST-based extractor, per-stage journey_logstransitions for funnel metrics, and an “open in editor” source-location affordance. The Studio view is a dagre-laid-out flow with decision nodes, forks, and inline email preview, plus a drop-off funnel, an AI-share button, open-in-IDE, and Mermaid / image export.
  • The build now fails on an unregistered journey email template key. sendEmail’s template is typed against the registered-key union (TemplateName) instead of string, so a journey pointing at a template that was never registered is a compile error at every send site. As a runtime backstop, @hogsend/email’s getTemplate throws a loud error naming the bad key and the registered ones.
  • Config ids are boot-validated — misconfiguration fails loud instead of silently mis-behaving. ANALYTICS_PROVIDER throws at boot when the selected id resolves to no registered provider (symmetric with EMAIL_PROVIDER); ENABLED_JOURNEYS throws on an id that matches no journey, with a did-you-mean; JourneyRegistry.register()throws on a duplicate id instead of silently double-routing; and every template’s category is checked against the email-list namespace — unknown throws, and excluding an opt-in list via ENABLED_LISTS throws (it would un-gate consent at send time). POST /v1/emails rejects an unknown category too.

Upgrade: pnpm up "@hogsend/*". Additive at runtime, but stricter on purpose: a misconfigured ANALYTICS_PROVIDER, ENABLED_JOURNEYS id, or template category that used to fail quietly now throws at boot, and a journey referencing an unregistered template is now a compile error.

v0.38.0

Bulk suppression import + a migration importer CLI

  • POST /v1/admin/suppressions/import (with a status-poll twin): async bulk import of unsubscribes, bounces, and spam complaints via an import-suppressions Hatchet task (CSV or JSON, batches of 500). Rows map onto the existing email_preferences semantics — no schema change — through the single upsertEmailPreference choke point, which gains an emitOutboundopt-out so a historical import doesn’t fan out a contact.unsubscribed event per row.
  • New hogsend import CLI migrates contacts and suppression state into a running instance over the admin API: hogsend import csv for generic header CSVs, hogsend import loops for the Loops dashboard export (typed properties + per-contact suppression lookups), and hogsend import customerio for the Customer.io async people export plus its ESP bounce/spam lists. Source requests are rate-limited with retry-on-429 backoff and job polling aborts loudly rather than hanging.
  • The send-time suppression gate now aggregates per address: checkSuppression reads every email_preferences row for the recipient (the PK is (user_id, email)), so a suppression imported before the contact existed still blocks the send.
  • Long-running journeys hardened against silent stalls. A multi-step once journey used to strand in waiting after its first durable wait — on an eviction-capable engine the entry-limit guard re-ran on replay-from-top before the run-id recovery lookup and short-circuited with already_entered_once. The recovery lookup now runs first (0.38.1), and 0.38.2 adds a 15-minute scheduleTimeoutso a resume survives a redeploy saturating worker slots, plus a timezone-lookup fallback so a transient DB blip can’t strand the row.
  • Consent-gated storage seam: @hogsend/js exports its storage adapters (createMemoryStorage / createLocalStorage) and HogsendProvider accepts a storage prop, so a host app can keep the SDK from persisting hs_anon_id until the visitor grants storage consent.

Upgrade: pnpm up "@hogsend/*". Additive — no forced migration.

v0.37.0

In-app component kit

  • A survey / rating primitive: a surface-neutral <Survey> email component and an in-app survey feed block, plus sendSurvey(). Answers ride the existing event spine (no new write path) and are readable from journeys via ctx.waitForEvent. A read-only GET /v1/admin/reporting/breakdown aggregates any event by a property value — count, average, optional NPS.
  • <PreferenceCenter> — per-category × per-channel notification preferences over usePreferences, bundleable into <FeedPopover> as a tab, backed by a new read-only GET /v1/lists catalog.
  • Swipe-to-archive is now a first-class affordance in @hogsend/react (pointer / touch swipe plus an accessible archive button), the toast gains a polished default skin and first-class custom rendering (renderToast), and the notification bell badge box-sizing is fixed so the unread count renders as a solid pinned circle under any host reset.
  • The feed is responsive (0.37.1–0.37.3): it sets its own type baseline so items don’t balloon to the host font-size, the scale / nps row shrinks to fit instead of wrapping in a narrow (380px bell) popover, long titles and bodies clamp to a token-driven N-line ellipsis, and new items fade + lift in behind a prefers-reduced-motion gate.

Upgrade: pnpm up "@hogsend/*". @hogsend/js / @hogsend/react are opt-in — not create-hogsend scaffold defaults.

v0.36.0

The client-side layer

  • @hogsend/js — a zero-dependency browser core: identity, capture, preferences, an in-app feed, banners, toasts, and a reactive store.
  • @hogsend/react — a provider, hooks, and the NotificationBell / FeedPopover / NotificationFeed / Banner / Toast components with a --hs-* themed override surface.
  • The engine pieces that power them: publishable-key (pk_) browser-ingest auth (per-key origin allowlist, reflective CORS, an allowed_origins migration); the feed backend (feed_items table, sendFeedItem() + send-feed workflow, recipient-scoped /v1/feed/* routes with SSE fan-out); sendBanner(), and the generateUserToken mint helper for identified browser sessions. Every client interaction is a first-party inapp.* / banner.* event through the ingest spine, so it can trigger a journey and fan to PostHog.
  • 0.36.1 fix: a server-side re-ingest keyed by a contact’s own canonical key (its anonymous_id) minted a phantom “identified” twin that 403’d the visitor out of their own feed (anonymousId is not addressable). Engine-internal re-emits now carry the unforgeable contact row id and pin to that exact row — never value-resolving, never minting; the public routes can’t supply it, so the anti-impersonation boundary is unchanged.

Upgrade: pnpm up "@hogsend/*". @hogsend/js / @hogsend/react ride the engine version line but are opt-in.

v0.35.0

A co-working AI agent in Studio

  • An in-Studio co-working agent: a bottom-right chat panel that reads the live instance (contacts, events, journeys, buckets, sends) and can act through the existing data plane — every write gated behind a human-in-the-loop confirmation.
  • Engine: a streaming POST /v1/admin/agent/chat (Vercel AI SDK + OpenRouter, default z-ai/glm-5.2) under the admin auth / rate-limit / audit stack; the OpenRouter key never leaves the server. Read tools auto-run; write tools mint a single-use, encrypted, Redis-burned proposal token that only POST /v1/admin/agent/confirm can execute (idempotent, audited).
  • Studio: a launcher → slide-over drawer, multi-chat, markdown rendering, tool-call cards, a tier-driven confirmation card, and per-message edit / rollback / regenerate over a virtualized thread. Opt-in and fail-closed: with no OPENROUTER_API_KEYthe panel shows a calm “not configured” state and the routes 503.
  • 0.35.1 fix: scaffolded apps crashing at boot (“Dynamic require of X is not supported”) — the engine added ai + @openrouter/ai-sdk-provider but the create-hogsendtemplate never declared them, so a consumer’s tsup bundled the CJS ai tree into the ESM dist. The template now declares them (plus svix, picocolors) so tsup externalizes them, and verify-scaffold now boots the built app to catch this class of regression.

Upgrade: pnpm up "@hogsend/*". The agent is opt-in — set OPENROUTER_API_KEY to enable it.

v0.34.0

Events fan out to PostHog; Discord identity, corrected

  • Every ingested event can now mirror into the active analytics provider from the ingest spine, keyed to the resolved canonical contact key. Opt-in via analytics.eventMirror (or the ANALYTICS_EVENT_MIRROR env override), default off. It excludes source: "posthog" events (echo-loop guard), supports allow / deny event-name filters, and fires once on the fresh-insert side of the ingest idempotency guard, so retries never double-capture.
  • Discord inbound transforms no longer mint userId: "discord:<id>" — a pre-link member is anonymous (keyed by the discord_id column), so a later /link merges it into the email / web contact in the correct direction (the Discord person folds onto the canonical one, not the reverse).
  • Each inbound event now carries the actor’s own snowflake in its properties (authorId / reactorId / memberId), so role grants and DMs fire for members who haven’t linked yet, and the connector-action contact resolver widens to match anonymous_id and the uuid id column too.

Upgrade: pnpm up "@hogsend/*". The PostHog event mirror is off until you set analytics.eventMirror.

v0.33.0

removeRole — Discord tenure ladders

  • A removeRole outbound action mirroring grantRole (bot-REST DELETE, idempotent, soft-fails on an unresolved member or a permission / hierarchy 403), so a journey can demote as well as promote — a Stranger → Piglet → Hog member lifecycle (drop Stranger on /link, drop Piglet on graduating to Hog after a 7-day tenure).

Upgrade: pnpm up "@hogsend/*". Additive.

v0.32.0

Managed-link campaigns + connector engagement events

  • link.clicked is now a first-party bus event: a click on any non-email managed link (Discord, SMS, referral, a standalone Studio link) re-ingests through the journey pipeline, so a journey can trigger on — or ctx.waitForEvent for — a click of a specific managed link (filter by linkId / campaign). Gated on !isBotso unfurl / prefetch bots are suppressed, and on a personal link’s distinctId so public links carry no person.
  • ctx.waitForEvent gains an optional where predicate (the same model as trigger.where) so a journey can await a specific link’s click mid-run — an engine-side durable re-arm loop with a persisted wait_deadline that survives Hatchet replay. ctx.history.events gains an event-name filter.
  • Connector engagement events: Discord reactions fan out into a reactor-keyed discord.reaction_added (carrying the target author for distinct-people counting) plus, when the author is known, an author-keyed discord.reaction_receivedpowering “your post resonated with N people”. Adds discord.reaction_removed and a grantRole outbound action for the community-gamification loop (count an engagement event → grant a role + DM).
  • 0.32.1 fix: preserve Hatchet’s this binding in the journey side-effect memoize — an unbound ctx.memo threw Cannot read properties of undefined, crashing every journey side effect (sendEmail / sendConnectorAction / ctx.trigger) the moment an eviction-capable engine made supportsEviction true.

Upgrade: pnpm up "@hogsend/*". Additive.

v0.31.0

Studio-styled connect page + platform logos

  • The engine-served connect page (GET /connect/<connector>) — where a contact confirms a Telegram or Discord email link — is restyled to the Hogsend Studio design language: ink surface, hairline card, the real Telegram / Discord logo, and an “if this wasn’t you, ignore this” reassurance line. It’s engine-owned, so every cold-connect connector inherits the look.
  • Hardened: the branding JSON embedded in the page’s inline <script> is escaped against a </script> breakout, the new iconSvg branding field is shape-checked (fails closed to the emoji badge), the page clears WCAG AA contrast, and it no longer pulls a third-party webfont.
  • create-hogsend is realigned to the engine version line — it had drifted to 0.22.0 while the line reached 0.30.0, so create-hogsend@latest scaffolded a stale app. release-doctornow holds the scaffolder to the line so it can’t fall behind again.

Upgrade: pnpm up "@hogsend/*". Additive — the connect page is engine-owned, so Telegram and Discord both pick up the new look.

v0.30.0

Discord adopts the cold-connect link flow

  • Discord drops the typed /verify 6-digit code. /link now emails a one-click confirm link — the same cold-connect flow Telegram uses. The bind happens in the browser when the user clicks the link, folding discord_id + email onto one contact and identifying the PostHog person client-side.
  • @hogsend/plugin-discord InteractionDeps is reworked (breaking): the code-flow callbacks (mintCode, sendLinkCode, redeemCode) are replaced by a single requestConfirm that mints a server-sealed cold-connect token and emails the confirm link. The mint throttle moved into mintConfirm (Redis-INCR, fail-closed).

Upgrade: pnpm up "@hogsend/*". Breaking for consumers wiring Discord /link — swap the code-flow callbacks for requestConfirm; /verify and the typed-code path are removed.

v0.29.0

createColdConnect() — one cold-connect primitive for every channel

  • New createColdConnect engine primitive extracts the Telegram link flow (/link <email>→ emailed confirm link → click → server-sealed bind → client-side posthog.identify) into a channel-agnostic factory, so Discord, Telegram, and future connectors share one mechanism. It owns the sealed-token store (Redis), the connect page, and the peek → ingestEvent → consume exchange, returning { mintConfirm, confirmUrl, routes }.
  • Security invariants are baked in: the bind runs only on a human POST (never a GET prefetch); ids come solely from the sealed token (no graft); single-use peek-then-consume so a webhook retry can’t burn the link; a fail-closed Redis-INCR mint throttle; and cross-connector token isolation (a binding.connectorId === connectorId assert, 410 on mismatch). The exchange returns the canonical contactKey, which the page hands to posthog.identify — keyed to the server-proven id, never a client-supplied one.
  • CreateAppOptions.routes now accepts a single fn or an array, so a consumer can mount [existingRoutes, coldConnect.routes] without clobbering. @hogsend/plugin-telegram is refactored onto the primitive; the basePath (/connect/telegram) is unchanged, so confirmation emails in flight keep resolving.
  • The marketing site’s PostHog init now sets cross_subdomain_cookie: true so a consented visitor’s distinct_id is written to a .hogsend.com cookie — letting a connect page served off the API host read the existing id and fold prior browsing into the proven identity. Pre-consent behaviour (memory-only, no cookie) is unchanged.

Upgrade: pnpm up "@hogsend/*". Additive — createColdConnect is a new export and the routes array form is backward-compatible.

v0.28.0

Telegram connector + live-only journey index

  • New @hogsend/plugin-telegram — an inbound webhook connector for messages, the /start deep-link, and a /link email-confirm cold connect, with journey-callable sendMessage/dmactions. Linking uses Redis-token peek-then-consume, so a Telegram webhook retry can’t burn a link mid-flight.
  • Engine: uq_user_journey_active is now a PARTIAL unique index scoped to live rows (status IN ('active','waiting')), so an unlimited journey can complete more than once per user — the old full (user_id, journey_id, status) index threw 23505 on the second completion. Ships migration 0029.
  • contacts.properties.telegram now deep-merges, mirroring discord.

Upgrade: pnpm up "@hogsend/*". Additive; run migration 0029 to swap the journey index to the live-only partial.

v0.27.0

Generic first-party link tracker

  • The email link-tracking machinery is now a channel-agnostic primitive: mintLink(...) inserts a durable links row (operator/campaign identity) plus a tracked_links click-counter that back-references it via link_id, and returns the /v1/t/c/:id redirect URL — so Studio, Discord, SMS, or a share link can mint a tracked link, not just email. Email is unchanged: it still rewrites HTML at send time with link_id NULL, so the two stay independent consumers of one click spine.
  • Share-safe by construction: a link carries a person token (a distinctId the click can stitch) ONLY when type: "personal". A public link never carries one, so a reshared public link attributes by campaign only. Destinations are validated http(s) at mint time, closing the latent open-redirect.
  • The hs_t identity-token redirect is now single-use: the first POST /v1/t/identify exchange wins, a replayed/reshared token is a 200 no-op (Redis SET NX on a sha256 of the token). Best-effort — a Redis fault degrades to the pre-burn behaviour rather than coupling the exchange to Redis liveness.
  • A new Studio “Links” view mints personal/public links, copies the short URL, shows per-link click counts, and archives — backed by admin CRUD at /v1/admin/links. @hogsend/db adds the links table + tracked_links.link_id FK (additive migration 0028).

Upgrade: pnpm up "@hogsend/*". Additive; run the 0028 migration.

v0.26.0

Connector DX polish

  • Readiness now reads the owned heartbeat: connect-info's ingressSecretConfigured becomes legacyIngressSecretConfigured (deprecated one minor) and workerOnline drives it — the inline runtime never uses an ingress secret. A runtime that can't take its lease for ~30s (Redis down or contended) now logs a loud, actionable error instead of silently never connecting.
  • The Discord gateway runtime auto-registers the /link and /verify slash commands (global, idempotent) the moment the socket comes up — no separate discord:register-commands step, and it self-heals after a token rotation. Exports registerSlashCommands + LINK_VERIFY_COMMANDS.
  • hogsend connect discord --status drops the stale ingress-secret line when the worker is online, adds a worker-offline hint, and returns a 404-specific error when the consumer's /secrets + /wire routes aren't mounted.
  • Studio renders the rich gateway card for any transport === "gateway" connector (not the literal discord id) — a second Discord bot gets its own card for free.

Upgrade: pnpm up "@hogsend/*". Additive; ingressSecretConfigured stays one more minor as legacyIngressSecretConfigured.

v0.25.0

The connector runtime

  • The Discord gateway socket now runs inside the Hatchet worker — no separate service, no CONNECTOR_INGRESS_SECRET. A Redis leader lease holds exactly one socket per bot token with bounded automatic failover, and only the lease-holder writes the liveness heartbeat Studio reads, so a stray process cannot fake an online bot.
  • Outbound actions need no socket: sendConnectorAction(...) invokes registered defineConnectorActions from a journey and works with the inbound gateway off.
  • @hogsend/plugin-discord ships createDiscordRuntime and discordActions (sendChannelMessage, broadcastToChannel, mentionMembers, mentionRole, dmMember); register them via createHogsendClient and wire the runtime via createWorker.
  • The seam is connector-agnostic: a second connector (Slack) implements only defineConnector plus a ConnectorRuntime factory and reuses lease election, the heartbeat, and the admin projection.

Upgrade: pnpm up "@hogsend/*". Additive and opt-in — activation is automatic when a gateway connector and its bot token are present; the standalone Gateway worker from 0.22 remains an escape hatch (CONNECTOR_RUNTIME_HOST=standalone).

v0.24.0

AI agents on your event stream

  • ctx.history.events({ userId, limit?, within? }) reads a user's recent events newest-first (with RecentEventsOptions / RecentEvent types) — the foundation for an agent's context bundle.
  • A freshly scaffolded app ships a working Tier-1 AI onboarding journey (src/agents/, user context backed by ctx.history.events()) and gains ai + @ai-sdk/anthropic; new docs cover three AI SDK integration tiers — inline, tools, and Eve durable human-in-the-loop.
  • BYO webhook-source secrets: a consumer-defined signature source now resolves its secret from process.env[auth.envKey] when the engine's validated env doesn't declare that key — still fail-closed, so an unset secret is a 401.
v0.23.0

One PostHog person per contact

  • Identity stitching ends one-email-many-persons fragmentation: every anonymous id a person carries is absorbed, while still anonymous, into one canonical and ever-identified distinct_id — the Hogsend contact key.
  • Provider-neutral by contract: mergeIdentities plus an identityMerge capability on AnalyticsProvider (distinctId survives, alias is absorbed). @hogsend/plugin-posthog implements it via native client.alias in the correct direction, and merges are idempotent so a retry never re-aliases.
  • POST /v1/events threads an anonymousId so the contact key can equal the browser's anon id with no merge at all; tracked links carry scoped identity tokens redeemed server-side at /v1/t/identify, with referral links token-less by default.
  • 0.23.1 fix: the admin Suppressions All view built no filter and listed every contact as suppressed — display only, deliverability was never affected (the send-gate blocks on suppressed / unsubscribedAll). It now restricts to genuinely-suppressed recipients.

Upgrade: pnpm up "@hogsend/*". Additive and off by default — no forced migration.

v0.22.0

Discord: events, identity, and outbound

  • New @hogsend/plugin-discord — both faces of one integration under meta.id = "discord". A long-lived Gateway worker (its own process) feeds discord.message_sent, discord.reaction_added, discord.member_joined, and discord.presence_active into ingestEvent, stored on the contact; bot, webhook, and system messages and offline presence are dropped.
  • contacts.discord_id is a new indexed merge key — a fourth identity Kind — so a Discord member resolves to the same contact as their product activity and email.
  • In-Discord linking: /link opens an email modal and mails a 6-digit single-use code (15-minute TTL, hashed at rest, rate limited); every interaction is ed25519-verified with a ±300s replay window, and a connector_link_codes table backs the codes.
  • Outbound: discordDestination posts one Discord-markdown line per lifecycle event to a channel on the durable outbound spine — via a no-bot-token incoming webhook (config.webhookUrl) or bot-REST (config.channelId).

Upgrade: run db:migratecontacts.discord_id and connector_link_codes are schema changes. The plugin is consumer-mounted; run the Gateway worker as its own process.

v0.21.0

Keyless PostHog connect

  • hogsend connect posthog runs the OAuth handshake first — no phc_ paste needed. It mints and persists the webhook secret server-side and grabs the project's public key on the way through; the inbound webhook source resolves that secret from the credential store at request time, so the loop verifies with no redeploy.
  • The OAuth scope set is front-loaded (4 → 13) so later features land without forcing a reconnect; connect-info surfaces a scopeGap to nudge already-connected users to re-consent, and the create-hogsend scaffold makes the phc_ paste optional.
  • 0.21.1 fix: disconnect now also purges the derived credential row (the minted secret and grabbed phc_); the inbound source's secret cache is busted the moment connect mints a secret, so it is enforced immediately instead of after a ~30s recheck.

Upgrade: pnpm up "@hogsend/*". Additive; existing POSTHOG_PERSONAL_API_KEY setups keep working.

v0.20.0

One command to connect PostHog

  • hogsend connect posthog runs a public-client OAuth flow (PKCE S256, loopback callback, no client secret) and discovers the OAuth server from your own PostHog host — so the region is always right and self-hosted instances degrade to the personal-key path. It stores the credential encrypted at rest and provisions the PostHog → Hogsend webhook destination idempotently, adopting an existing one instead of duplicating.
  • A credential stored at runtime is picked up by the running API and worker within ~30 seconds — no restart. Person reads prefer the OAuth token and fall back to POSTHOG_PERSONAL_API_KEY.
  • It refuses to wire an unauthenticated endpoint: provisioning fails when POSTHOG_WEBHOOK_SECRET is unset rather than exposing one.
  • Contact → person propagation: the posthog destination's syncPersons turns contact.created / contact.updated into $set captures under the contact's canonical key. Only properties travel — never email or identifiers.

Upgrade: pnpm up "@hogsend/*" and run db:migrate (the OAuth connect flow adds a provider_credentials table). Everything is additive; existing PostHog setups keep working on POSTHOG_PERSONAL_API_KEY.

v0.19.0

Provider-neutral analytics, and PostHog reads that work

  • The AnalyticsProvider contract — the analytics sibling of EmailProvider, authored via defineAnalyticsProvider — lands in @hogsend/core with person reads, person writes (set / setOnce / unset), and capture. The analytics client option now mirrors email; legacy PostHogService inputs are adapter-wrapped and keep working.
  • PostHog person reads are fixed — they were silently dead (the write-only phc_ project key sent to the ingestion host on a legacy path). Reads now use POSTHOG_PERSONAL_API_KEY against the private API host with one-shot project-id discovery.
  • Without the personal key, reads soft-fail to contact-property fallbacks — now surfaced once at boot and by hogsend doctor instead of silently. Person writes need no extra credential; they ride the capture pipeline.

Upgrade: pnpm up "@hogsend/*". To turn on person reads, set POSTHOG_PERSONAL_API_KEY (scoped person:read); the scaffold's env.example documents the two-credential model.

v0.18.0

Closing the analytics identity loop

  • POST /v1/events now returns contactKey — the contact's canonical key (external_id ?? anonymous_id ?? id), the same key destinations emit as userId and hs_t tokens resolve to — so a consumer site can identify() its analytics session against the contact with no PII round-trip.
  • Identity resolution round-trips that key: a key that left the system (Hatchet payloads, destination userIds, hs_t stitches, forwarded PostHog webhooks) always resolves back to the same live contact instead of minting a duplicate.
v0.17.0

Studio, restyled

  • Hogsend Studio moves onto the Hogsend design system — the same dark surface as the site and docs.
  • 0.17.1 fix: the password-reset link now lands on the reset form, not the login card. The bare /studio redirect was dropping better-auth's ?token=… query string.
v0.16.0

The where builder, the hosted answer page, and cross-device identity

  • Journey conditions read like code: where: (b) => b.prop("score").lte(6) on trigger and exitOn, resolved once at definition time to the same plain data — Studio and the admin API are unchanged.
  • Semantic links without a landing page: href={HOSTED_ANSWER_HREF} lands answers on an engine-hosted page with an optional comment box; comments arrive as <event>.comment events.
  • Cross-device identity, opt-in: TRACKING_IDENTITY_TOKEN appends an encrypted one-hour hs_t token to tracked redirects; the landing site exchanges it at POST /v1/t/identify and calls posthog.identify — the email click and the web session become one person.
  • ctx.waitForEvent accepts lookback to catch answers landing between two waits.

Upgrade: pnpm up "@hogsend/*". All three are additive; the identity token is off until you set TRACKING_IDENTITY_TOKEN=true.

v0.14.0

Semantic links — in-email surveys and one-tap actions

  • <EmailAction> (new in @hogsend/email): an anchor whose click fires a real event — an NPS score, a yes/no — through the full ingest pipeline. The metadata is lifted into the tracked link at send time and never reaches the inbox.
  • First answer per send wins, and confirmation is deferred past a 30-second window so scanner click-bursts (Outlook SafeLinks, Proofpoint) are judged in full — including the scanner's first click — before anything is recorded.
  • ctx.waitForEvent now returns the matched event's properties, so a journey branches on the answer directly; an optional lookback window closes the gap between back-to-back waits.
  • New email.action outbound event — the PostHog preset captures it under your event name with the answer's properties flattened, ready for insights and cohorts.

Upgrade: pnpm up "@hogsend/*" and run db:migrate (one additive migration on tracked_links). The scaffold ships a feedback-checkin example showing the whole loop.

v0.11.0

CLI-first Studio auth

  • Public Studio sign-up is closed: there is no unauthenticated network path that creates a user.
  • First admin via hogsend studio admin create (new CLI command, with reset and list) or env bootstrap (STUDIO_ADMIN_EMAIL / STUDIO_ADMIN_PASSWORD) on a zero-user database.
  • Self-service password reset, wired through the engine mailer; tokens are single-use with a 15-minute TTL.
  • Auth rate limiting is now shared across replicas via Redis.

Upgrade: pnpm up "@hogsend/*". If your Studio admin already exists, nothing changes; new deploys set STUDIO_ADMIN_EMAIL or run the CLI once.

v0.10.0

Bring your own email provider

  • Provider-neutral EmailEvent webhook contract and an HTML-only send wire: the EmailProvider is now a dumb wire, and rendering, preferences, first-party tracking, and the send log stay engine-owned — so everything survives a provider swap.
  • New opt-in @hogsend/plugin-postmark: swap with EMAIL_PROVIDER=postmark. Resend stays the default.
  • Bounce normalization: auto-suppression now fires only on permanent bounces; transient bounces are recorded without suppressing.
  • Provider-native open/click tracking is forced off where possible — first-party tracking is the source of truth.

Upgrade: Postmark deploys need POSTMARK_SERVER_TOKEN; Resend deploys change nothing.

v0.9.0

Outbound destinations

  • The durable outbound webhook spine becomes a fan-out engine: defineDestination() plus shipped presets for PostHog, Segment, and Slack alongside signed Standard-Webhooks.
  • Every delivery reuses the same retry/backoff/dead-letter machinery.
  • ENABLE_POSTHOG_DESTINATION auto-seeds a PostHog endpoint on the email funnel so the full lifecycle fans out durably.
  • Breaking: ctx.posthog.capture and ctx.identify were removed from the journey context — PostHog is now one destination among many; the context keeps only vendor-neutral orchestration primitives.

Upgrade note: open/click events now emit per hit (not first-touch only) — size webhook consumers accordingly.

v0.8.0

Outbound webhooks + inbound presets

  • Signed outbound webhook stream: managed endpoints, per-endpoint retry/backoff, dead-letter queue, and a reaper that re-drives due retries.
  • Inbound integration presets for Clerk, Supabase, Stripe, and Segment — set the secret env var and the signature-verified route auto-enables.
  • hogsend webhooks CLI command and verifyHogsendWebhook in the client.
v0.7.0

The front door: Data API + client SDK

  • Public /v1 data plane: contacts, events, transactional emails, lists, and campaigns behind an hsk_ API key.
  • New @hogsend/client typed SDK over the data plane.
  • Identity gains email/anonymous keys with a real merge/alias resolver.
  • Lists are code-defined over the preference store; campaigns are durable, idempotent, preference-checked broadcasts.
Stay on the line

Start on the latest release

One scaffold command pulls v0.44.0; one pnpm up keeps you current. Your journeys live in your repo, so an upgrade is a dependency bump — never a fork merge.

Free to self-host · One scaffold command · No per-contact billing

pnpm dlx create-hogsend@latest my-app