We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide12 min read

Loops, product email without two systems

Loops sends lifecycle and transactional email from one place. Events, automations, contact based pricing, and a comparison with Resend and Mailchimp.

Loops, product email without two systems

An application with users sends two kinds of message. Transactional ones: registration confirmation, password reset, an invoice. And lifecycle ones: a welcome after signup, a reminder before a trial ends, a message to somebody who stopped logging in.

Historically two separate tools handled them, since the first demands reliability and speed while the second demands an editor and segmentation. Loops combines both in one place and that is its whole idea.

Contacts and events

The data model rests on contacts with arbitrary properties and on events you send from the application.

Code
TypeScript
import { LoopsClient } from 'loops'

const loops = new LoopsClient(process.env.LOOPS_API_KEY!)

await loops.createContact('anna@example.com', {
  plan: 'trial',
  companySize: 12,
  source: 'referral'
})

await loops.sendEvent({
  email: 'anna@example.com',
  eventName: 'completed_setup',
  eventProperties: { steps: 4 }
})

An event is an automation trigger here, and contact properties serve segmentation and content personalisation. That means the logic deciding who receives which message lives in the tool rather than in your code.

That split is an advantage and a trap at once. An advantage, since whoever owns communication can change a condition without a deployment. A trap, since system behaviour then depends on configuration absent from the repository and changed without review.

A sensible compromise keeps content and timing decisions in the tool and the decision whether to send an event at all in code.

Code
TypeScript
export async function onSetupCompleted(user: User) {
  if (user.plan === 'enterprise') return
  if (!user.marketingConsent) return

  await loops.sendEvent({
    email: user.email,
    eventName: 'completed_setup',
    eventProperties: { steps: user.completedSteps }
  })
}

The two conditions at the top are what belongs in code. Checking marketing consent on the application side rather than in the tool's configuration is the only version you can test and demonstrate under audit. Business conditions then live in code with tests, while campaign structure sits where it can be fixed quickly.

Transactional messages

You send a transactional message through a separate call naming a template and variables.

Code
TypeScript
await loops.sendTransactionalEmail({
  transactionalId: 'password-reset',
  email: user.email,
  dataVariables: {
    name: user.name,
    link: `https://app.example.com/reset/${token}`
  }
})

Three things deserve setting up from the start. The first is send failure handling: a password reset that never arrived is a support ticket, so a failed call belongs in a retry queue rather than vanishing into a log.

The second is separating environments. Sending from tests to real addresses happens to everybody once, and a separate key with separate templates rules it out.

The third is an unsubscribe option. Transactional messages by definition need no marketing consent, but the line is thin, and a feature announcement sent as transactional is a legal problem rather than a technical one.

Lifecycle automations

This is the main reason for reaching for the tool, so it helps to know what can be built.

A typical flow starts from an event: a user registered. Then a sequence with intervals follows: a welcome immediately, a tip after a day, a reminder about unfinished setup after three days, but only if they still have not finished it.

That condition is the heart of it. A sequence without a state check sends a reminder about something the user already did, and that is the commonest reason people unsubscribe from such messages.

The second typical flow reacts to inactivity. A user who has not logged in for two weeks receives a message asking what went wrong. Effectiveness there follows directly from whether the message reads as a question or as a campaign.

The third is a reminder before a trial ends, where the content depends on how much the user actually did in the application. Somebody who completed the whole setup needs a different message from somebody who logged in once.

Syncing with the application database

Contact properties in an email tool are a copy of data from your database, and every copy drifts over time. Settle how to prevent that before the drift shows up in message content.

Three approaches work. The first updates on change: code changing a user's plan also sends a contact update. Simplest, though it is easy to miss a place where data changes differently.

The second syncs on a schedule. A job running daily walks changed records and updates contacts in batches. Slower to react and more resistant to omissions, since it covers every change whatever its source.

The third treats events as the only source. Rather than syncing properties, you send events describing what happened and segmentation rests on them. That is the cleanest approach and needs the event set planned up front.

In practice combining the first two works: immediate updates on important changes and a nightly run as a safety net. With a back end built on Drizzle or another data access layer, such a run is a dozen or so lines of code.

Code
TypeScript
const changed = await db
  .select()
  .from(users)
  .where(gt(users.updatedAt, yesterday))

for (const u of changed) {
  await loops.updateContact(u.email, {
    plan: u.plan,
    companySize: u.companySize,
    lastActive: u.lastLogin.toISOString()
  })
}

The update method creates the contact if it does not exist, so there is no need to check separately. Do bear the rate limit in mind, though: a loop over tens of thousands of records with no delay gets rejected halfway through, and a nightly run has no reason to hurry.

Decide what happens to a deleted user too. An account removed in the application should disappear from the contact list as well, otherwise a message reaches somebody who asked for their data to be erased.

Code
TypeScript
export async function deleteAccount(user: User) {
  await db.delete(users).where(eq(users.id, user.id))

  try {
    await loops.deleteContact({ email: user.email })
  } catch (error) {
    await queue.add({ type: 'delete-contact', email: user.email })
  }
}

The error handling is not decoration here. The database delete succeeded while the call to the external service may not have, and then the address stays on the list with no trace of it anywhere in your system. Queueing a retry closes that gap.

Pricing

PlanCostWhat it covers
Free0 USDOne thousand contacts, four thousand messages monthly
Paid fromaround 49 USD monthlyFive thousand contacts, unlimited sending
Higher tieraround 99 USD monthlyTen thousand contacts
Enterprisequoted individuallyLarger scale and organisational requirements

Billing runs per contact rather than per message, and that is the most important thing when comparing alternatives. With frequent sending that model comes out cheap, since message count does not touch the bill. With a base of a hundred thousand addresses you write to quarterly, it comes out dear.

A simple test before deciding: divide the expected monthly cost by messages sent and compare against a send billed option. The result usually settles it faster than a feature comparison.

Check how contacts are counted too. Inactive, unsubscribed, and bounced addresses may or may not count towards the tier, and on a long tail list that is a difference of tens of percent.

Loops against the alternatives

ToolStrengthWeaknessPick it when
LoopsLifecycle and transactional together, simple modelContact billing on infrequent sendingApplication with users and onboarding sequences
ResendSend based billing, templates in codeFewer marketing toolsTransactional messages from an application
MailchimpMaturity, elaborate campaignsHeavy, poorly fitted to a productNewsletters and marketing outside the application
Your own sendingFull control, low unit costDeliverability and maintenance on your sideHigh volume with your own infrastructure

Choosing between the first two rows depends on whether you need a marketing layer. If you send only confirmations and password resets, send based billing is cheaper and simpler. If you build sequences driven by user behaviour, the bundle in one place saves joining two systems.

Consider the last row only at high volume and with full awareness that deliverability is a problem in itself. Domain authentication setup, sending address reputation, and bounce handling are work invisible until messages start landing in spam.

Note too that email is rarely the only channel. In app notifications, chat messages, and interface reminders handle some cases better, since they arrive immediately and compete with nothing else in an inbox. When building user onboarding, separate what goes by email from what should appear in the application itself, even through a simple notice built with your own components on Tailwind.

For automations spanning other systems, writing to a spreadsheet or notifying a team for instance, connecting this tool with Make or something similar often beats building everything in one place.

Deliverability, or whether the message arrives

The best written sequence achieves nothing if messages land in spam. That part sits partly with the service and partly with you.

Domain authentication is yours. Three records in your name configuration confirm the service may send on your behalf, and their absence is the commonest cause of trouble on a first rollout. It is a quarter of an hour's work, done once.

The second thing is separating traffic. Transactional and marketing messages sent from the same subdomain share reputation, so one poor campaign can affect password reset delivery. Separate subdomains split that risk.

The third is list hygiene. Bouncing addresses and ones unopened for a year lower reputation, and under contact based billing they cost money too. Removing them quarterly is cheap and improves both at once.

The fourth is content. A message that is one large image, one shortened through an external service, or one heavy with exclamation marks looks suspicious to filters whoever sent it. That sounds trivial and is sometimes why one message in a series does not arrive.

Testing and preview

A message sent to a thousand people with a broken condition cannot be recalled, so a few habits save trouble.

The first is a test send to yourself before starting a sequence. The editor preview shows layout, and a message in a real inbox shows how it looks in a mail client, which is often something else.

The second is checking personalisation on a contact with empty fields. A greeting reading "hello" with no name looks wrong and happens on every import with incomplete records. A fallback value settles it once.

The third is checking links. A link pointing at a test environment or containing a variable that was never substituted is the commonest bug in transactional messages.

The fourth is reading a sequence backwards. Exit conditions matter more than entry conditions, since they decide whether a user stops receiving messages after doing what was asked. A sequence with no exit condition writes to somebody who already bought.

Common mistakes

The first is a sequence without a state check. A reminder about a step the user already completed is the commonest reason for unsubscribing.

The second is sending from tests to real addresses. A separate key for the test environment costs a minute and guards against a message reaching a customer by mistake.

The third is marketing messages sent as transactional. The line is thinner than it looks and the consequences sit on the legal side rather than the technical one.

The fourth is no error handling on important messages. A password reset that failed belongs in a retry rather than vanishing into a log, where a user complaint discovers it.

The fifth is estimating cost without checking how contacts are counted. Inactive and unsubscribed addresses can shift the bill by tens of percent.

The sixth is keeping all decision logic in the tool. Business conditions without tests and without change history are where a bug surfaces only after a message went to everybody.

FAQ

What does Loops cost?

The free plan covers a thousand contacts and four thousand messages monthly. Paid plans start around forty nine dollars for five thousand contacts, and billing runs per contact count rather than per message sent.

How does it differ from Resend?

In billing and scope. Here you pay per contact and get a marketing layer with automations; there you pay per message sent and get chiefly reliable transactional delivery. For confirmations and resets alone the latter is cheaper.

Is it suitable for transactional messages?

Yes, it handles them alongside the rest, removing the need to maintain two systems. On critical messages, though, handle errors on your side, since a failed interface call is an event requiring a response.

When does contact based billing not pay off?

With a large base you write to rarely. A hundred thousand addresses at one message a quarter costs the same as a hundred thousand at daily sending, so for infrequent communication a send based model comes out cheaper.

What about deliverability?

The service handles sending address reputation and bounce processing, while authenticating your own domain is yours to configure. That is the first thing to do on rollout, since without it messages land in spam whatever the content's quality.

Documentation sits on the project site, and comparisons with other tools appear in a provider roundup.