Hogsend is brand new.Try it
Hogsend
Compare

Migrating to Hogsend

How to move your lifecycle email automation to Hogsend from Customer.io, Loops, Brevo, ActiveCampaign, or a custom setup.

This page is about moving to Hogsend from another lifecycle email platform -- translating your existing automation into a Hogsend app. (If you're looking for how Hogsend applies its own schema migrations across upgrades, that's a different thing entirely -- see the two-track migration model under Operating.)

Moving to Hogsend is more of a "rewrite the good parts" exercise than a traditional import. Because Hogsend is code-first, you're translating your existing automation logic into TypeScript rather than importing configuration files. The upside is that your journeys usually come out cleaner and more maintainable on the other side.

Start with a scaffolded app

Before you translate anything, you need somewhere for it to live. Hogsend is a versioned engine you consume as a package, so you scaffold a fresh app -- you do not fork the framework repo:

pnpm dlx create-hogsend@latest my-app
cd my-app

This emits a thin app that owns content only -- your journeys, email templates, webhook sources, custom routes, config, and your own database migrations -- and pins @hogsend/engine (plus the other @hogsend/* packages) to a single version line. Everything you migrate goes into this app's src/. The framework itself stays a dependency you upgrade with pnpm up "@hogsend/*".

Once the app boots, the migration is three concrete tasks: bring your contacts over, translate your journeys, and rebuild your templates.

What migrates

Contacts. Export your contact list from your current platform as CSV or JSON and run it through the CLI's hogsend import command, which maps fields to Hogsend's contact schema (externalId, email, properties), splits large lists into async import jobs, and polls them to completion. There are dedicated importers for Loops and Customer.io (below) and a generic CSV mode for everything else. Under the hood it's the bulk import endpoint (POST /v1/admin/contacts/import), which you can also call directly.

Suppression lists. Unsubscribes, hard bounces, and spam complaints must come with the contacts — see the next section.

Email templates. You'll rewrite these as React Email components, not import them. This sounds like more work than it is -- React Email templates are typically shorter and more maintainable than the markup most platforms export. They live in your repo under src/emails/ as .tsx components with typed props (rendered through @hogsend/email's machinery), so your editor catches mistakes before they reach a user's inbox.

Journey logic. This is the core of the migration. Each visual workflow or automation rule in your current platform becomes a defineJourney() call in TypeScript, living in your app's src/journeys/. The translation is usually straightforward: "wait 2 days" becomes ctx.sleep({ duration: days(2) }), "check if user did X" becomes ctx.history.hasEvent(), and branching logic becomes if/else statements.

Suppressions and unsubscribes

Do not import contacts without their suppression state. Someone who unsubscribed on your old platform has withdrawn consent — emailing them from Hogsend because the import dropped that bit is a compliance problem (GDPR, CAN-SPAM) and a fast route to spam complaints. Bounced addresses are just as important for a different reason: re-mailing addresses your old provider already knows are dead damages your sending domain's reputation with mailbox providers before your first real campaign.

Hogsend has a dedicated bulk endpoint for this: POST /v1/admin/suppressions/import (the CLI importers below call it for you). Each row is an email, an optional reason (unsubscribed | bounced | complained, default unsubscribed), and an optional externalId. Rows map onto email_preferences — the same table Hogsend's own unsubscribe links and bounce handling write to — so imported suppressions are enforced by the exact same send-time gate as native ones:

  • unsubscribed → the contact is globally unsubscribed
  • bounced → suppressed, with the bounce recorded
  • complained → suppressed as a spam complaint

Two properties of the import worth knowing: it's idempotent (re-running it never inflates bounce counts), and it's quiet — a historical import does not emit per-row contact.unsubscribed outbound events, so it won't trigger journeys or fan out webhooks for opt-outs that happened months ago on another platform. A suppression row is written even when no matching contact exists yet — the send-time gate aggregates every preference row for an address, so an imported suppression still blocks after the contact is created later.

The platform sections below cover where each platform keeps this data and what its API will and won't give you.

What doesn't migrate

Historical email analytics. Open rates, click rates, delivery stats, and engagement history from your previous platform stay there. Hogsend starts tracking from the first email it sends. If you need historical data for reference, keep read access to your old platform for a while.

Platform-specific integrations. If your current platform has deep integrations with tools that Hogsend doesn't connect to yet (e.g. a CRM sync, a landing page builder, SMS delivery), you'll wire them yourself -- install the service's SDK and call it from a journey -- or handle it outside Hogsend.

Visual workflow exports. There's no way to import a Customer.io or ActiveCampaign workflow file directly. The migration is a manual translation -- but it's also a good opportunity to simplify flows that have accumulated cruft over time.

From Customer.io

Customer.io is the most common platform teams migrate from, usually because of pricing.

1. Import contacts + suppressions

The CLI drives Customer.io's App API export for you — it creates the async people export, polls it, downloads the CSV, and imports it:

hogsend import customerio --app-key $CIO_APP_KEY --region eu --esp-suppressions

You need an App API key (Settings → API credentials → App API keys), not the Track API site/key pair. --region matches your account's data residency. --segment <id> exports one segment instead of everyone.

Field mapping:

Customer.io fieldHogsend field
id (preferred) or cio_idexternalId
emailemail
Other attributesproperties
unsubscribed attribute = truesuppression row, reason unsubscribed

--esp-suppressions additionally imports the ESP suppression list — GET /v1/esp/suppression/bounces as reason bounced and /spam_reports as reason complained. That list only exists when Customer.io's own ESP delivers your email; on custom-SMTP workspaces the command warns and moves on.

What Customer.io won't give you via API:

  • The workspace suppression list (people deleted + blocked via the Track API suppress call). There's no endpoint; support provides it as a CSV with SHA-256-hashed emails and IDs, so you can check membership against it but cannot recover the addresses.
  • Segment definitions. GET /v1/segments returns metadata only — the conditions of a dynamic segment are not in the response. Re-express the ones you need as trigger.where conditions or journey code.
  • Workflow graphs. Campaign endpoints return names and IDs, not the branch/delay/exit structure. Translating those by hand is step 2.
  • Historical events. The activities endpoints guarantee only about 30 days of history.

2. Translate workflows

Each Customer.io campaign or workflow becomes a journey file in your app's src/journeys/. The mapping is usually direct:

Customer.io conceptHogsend equivalent
Trigger (event-based)trigger: { event: "your_event" }
Filter / segment conditiontrigger.where conditions
Wait stepctx.sleep({ duration: days(n) })
Branch (if/else)TypeScript if/else
Send email actionsendEmail({ ... })
Goal / conversionexitOn: [{ event: "goal_event" }]
Frequency capentryLimit + suppress

A translated journey imports everything it needs from @hogsend/engine -- there are no monorepo-internal paths:

// src/journeys/welcome.ts
import { days, defineJourney, sendEmail } from "@hogsend/engine";
import { Events, Templates } from "./constants/index.js";

export const welcome = defineJourney({
  meta: {
    id: "welcome",
    name: "Welcome Series",
    enabled: true,
    trigger: { event: Events.USER_CREATED },
    entryLimit: "once",
    exitOn: [{ event: Events.USER_DELETED }],
  },
  run: async (user, ctx) => {
    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.ACTIVATION_WELCOME,
      subject: "Welcome — let's get you set up",
      journeyName: user.journeyName,
    });

    await ctx.sleep({ duration: days(2), label: "post-welcome" });

    const { found } = await ctx.history.hasEvent({
      userId: user.id,
      event: Events.FEATURE_USED,
    });
    if (!found) {
      await sendEmail({
        to: user.email,
        userId: user.id,
        journeyStateId: user.stateId,
        template: Templates.ACTIVATION_NUDGE,
        subject: "You haven't tried the key feature yet",
        journeyName: user.journeyName,
      });
    }
  },
});

Register it by adding it to the exported journeys array in your own src/journeys/index.ts -- the same array your app passes to createHogsendClient({ journeys }) and createWorker({ container, journeys }). You never edit anything inside @hogsend/engine. See the Journeys guide for the full authoring reference.

3. Map events

If you're already on PostHog, your events are already flowing. Point your PostHog webhook at Hogsend's ingest endpoint and the events Customer.io was receiving will now route to Hogsend journeys.

If Customer.io was receiving events directly from your app (not via PostHog), update your app's event calls to either send to PostHog (recommended) or directly to Hogsend's ingest endpoint.

From Loops

Loops has no bulk contacts API — the complete Contacts API surface is create/update/find/delete, with no list endpoint. The only full-audience export is the CSV download on the dashboard's Audience page. Download that first, then:

hogsend import loops --csv audience.csv --api-key $LOOPS_API_KEY

Field mapping:

Loops fieldHogsend field
userIdexternalId
emailemail
Names, source, userGroup, custom propertiesproperties
subscribed = falsesuppression row, reason unsubscribed

--api-key is optional but useful: the importer fetches your custom property definitions (GET /v1/contacts/properties) so number and boolean columns import typed instead of as strings, and lists your mailing lists for reference (per-list membership is not in the CSV export).

Loops' deliverability suppression list (hard bounces + spam complaints) is also not bulk-exportable — the API only offers a per-contact lookup. --check-suppressions (requires --api-key) queries it for every contact at Loops' 10 requests/second limit; the command prints a time estimate before starting, because on a big list this takes a while. Loops merges bounces and complaints into a single flag with no way to tell them apart, so a suppressed contact imports with reason bounced.

What Loops won't give you at all: send history and per-contact engagement (no endpoint of any kind), event history (/v1/events/send is write-only), campaign/workflow analytics, and rendered email HTML (email bodies export as Loops' proprietary LMX markup, not HTML — which matters less here, since you're rewriting templates as React Email anyway).

Then rewrite journeys and templates as below.

From Brevo, ActiveCampaign, or anywhere else

The process is the same pattern regardless of the source platform:

1. Export contacts + suppressions

Every platform has a contact CSV export. The generic importer maps email / externalId columns to identity and every other column to properties:

hogsend import csv --file contacts.csv

Export the platform's unsubscribe and bounce lists too (most platforms have a separate export for these, often under "campaign statistics" or "contact status"), shape them into email,reason columns, and import them as suppressions:

hogsend import csv --file unsubscribes.csv --suppressions

2. Rewrite journeys

Open each automation or flow in your current platform and translate it to a defineJourney() call in src/journeys/. Focus on the logic, not the UI representation. A 20-node visual workflow often collapses into 30--50 lines of TypeScript because most of the "nodes" are just wait steps and conditionals. Your scaffolded app ships with welcome and test-onboarding examples in src/journeys/ -- copy one as a starting point.

3. Rebuild templates

Author your React Email templates as .tsx components with typed props and register them in your template map, then reference each one by key in your journey's sendEmail() calls. The Email guide walks through the template package, the registry, and tracked sends.

4. Connect events

Point your event source (PostHog webhook, Stripe webhook, custom API calls) at Hogsend's ingest endpoint. If you're coming from a platform that was receiving events directly from your app, consider routing through PostHog first so you get both analytics and automation from a single event stream. For non-PostHog sources, define a webhook source with defineWebhookSource() and pass it to createApp(container, { webhookSources }) -- see the Events & webhook sources guide.

The PostHog advantage

If you're already on PostHog, the hardest part of setting up lifecycle automation is already done.

Event instrumentation -- deciding what to track, adding tracking calls, making sure data is clean -- is where most teams spend the majority of their setup time with any automation platform. With PostHog already in place, Hogsend just listens to events you're already capturing. The user_signed_up event that feeds your PostHog funnel is the same event that triggers your Hogsend welcome sequence. No duplicate instrumentation, no keeping two event schemas in sync.

PostHog person properties are also available in Hogsend via the @hogsend/plugin-posthog package, with Redis caching. Your journeys can branch based on the same properties you see in PostHog -- plan type, feature usage, company size, whatever you're tracking.

Or build on top

Hogsend is a platform, not a product. Extending it falls into two categories. Email and analytics are capability providers -- swappable implementations behind an engine-owned contract -- and @hogsend/plugin-resend and @hogsend/plugin-posthog are the bundled defaults and reference implementations. Everything you call out to -- Slack, Twilio, a CRM -- is just an integration: plain code, no contract, no framework.

Need Slack notifications when a high-value user enters a churn flow? Install the Slack SDK and call it. Want to send SMS via Twilio when a payment fails? Same thing. Need to sync contacts to your CRM? Push updates from a journey.

An integration is just a thin service wrapper you import directly into any journey as a standalone function call -- there's no plugin registration, no lifecycle hooks, nothing to inject into the engine. The Integrations & Plugins guide covers both categories in full.

The migration doesn't have to be a one-time event. Start with email, get your core journeys running, then extend into other channels and integrations as you need them. Your content is yours to grow -- and the engine underneath it upgrades cleanly with pnpm up "@hogsend/*", never a fork merge.