Utilizziamo i cookie per migliorare la tua esperienza sul sito
CodeWorlds
Torna alle collezioni
Guide37 min read

Loops

Loops is a modern email marketing platform designed specifically for SaaS. Offers automated sequences, transactional emails, event-triggered campaigns and simple API. Ideal Mailchimp alternative with better UX and sensible pricing.

Loops - Email Marketing dla SaaS

Czym jest Loops?

Loops to nowoczesna platforma email marketingowa zaprojektowana od podstaw z myślą o firmach SaaS (Software as a Service). W przeciwieństwie do tradycyjnych narzędzi email marketingowych, które próbują obsługiwać wszystkie typy biznesów, Loops koncentruje się wyłącznie na potrzebach produktów software'owych - od transactional emails przez onboarding sequences po product updates i lifecycle campaigns.

Loops został założony w 2022 roku przez Chrisa Frantz (byłego inżyniera Stripe) z wizją stworzenia "Stripe dla email marketingu" - platformy, która jest równie prosta w integracji jak Stripe dla płatności. Firma szybko zdobyła uznanie w społeczności startuków dzięki intuicyjnemu interfejsowi, nowoczesnemu API i przejrzystemu pricingowi.

Dlaczego Loops?

Kluczowe zalety Loops:

  1. Zaprojektowany dla SaaS - Nie jest kolejnym narzędziem "dla wszystkich" - jest precyzyjnie dopasowany do potrzeb software'owych firm
  2. Proste, nowoczesne API - Integracja w kilka minut zamiast dni
  3. Event-driven automation - Triggeruj emaile na podstawie zdarzeń w aplikacji
  4. Unified platform - Transactional, marketing i automated emails w jednym miejscu
  5. Developer-friendly - TypeScript SDK, webhooks, świetna dokumentacja
  6. Sensowny pricing - Płacisz za kontakty, nie za wysłane emaile

Loops vs alternatywy

CechaLoopsMailchimpSendGridCustomer.io
Główny fokusSaaSWszyscyDevelopersEnterprise
Łatwość użycia⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
APINowoczesne RESTLegacyDobryDobry
Sequences✅ Wbudowane✅ Złożone❌ Brak✅ Zaawansowane
Transactional✅ Wbudowane⚠️ Osobna usługa✅ Główna funkcja✅ Wbudowane
Event triggers✅ Proste⚠️ Złożone❌ Brak✅ Zaawansowane
Cena (5K kontaktów)$49/mo$59/mo$19.95/mo$150/mo
Free tier1,000 kontaktów500 kontaktów100 emails/day14 dni trial

Instalacja i konfiguracja

Instalacja SDK

Code
Bash
# npm
npm install loops

# yarn
yarn add loops

# pnpm
pnpm add loops

Podstawowa konfiguracja

TSlib/loops.ts
TypeScript
// lib/loops.ts
import { LoopsClient } from 'loops'

// Singleton pattern dla client
let loopsClient: LoopsClient | null = null

export function getLoopsClient(): LoopsClient {
  if (!loopsClient) {
    const apiKey = process.env.LOOPS_API_KEY

    if (!apiKey) {
      throw new Error('LOOPS_API_KEY is required')
    }

    loopsClient = new LoopsClient(apiKey)
  }

  return loopsClient
}

// Eksport dla wygody
export const loops = getLoopsClient()

Konfiguracja w Next.js

TSapp/api/loops/route.ts
TypeScript
// app/api/loops/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { LoopsClient } from 'loops'

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

export async function POST(request: NextRequest) {
  try {
    const { action, ...data } = await request.json()

    switch (action) {
      case 'createContact':
        const contact = await loops.createContact(data)
        return NextResponse.json({ success: true, contact })

      case 'sendEvent':
        await loops.sendEvent(data)
        return NextResponse.json({ success: true })

      case 'sendTransactional':
        await loops.sendTransactionalEmail(data)
        return NextResponse.json({ success: true })

      default:
        return NextResponse.json(
          { error: 'Unknown action' },
          { status: 400 }
        )
    }
  } catch (error) {
    console.error('Loops API error:', error)
    return NextResponse.json(
      { error: 'Failed to process request' },
      { status: 500 }
    )
  }
}

Zmienne środowiskowe

.env.local
Bash
# .env.local
LOOPS_API_KEY=your_api_key_here

# Opcjonalnie - custom endpoint dla self-hosted
LOOPS_API_URL=https://app.loops.so/api/v1

Zarządzanie kontaktami

Tworzenie kontaktów

Code
TypeScript
import { LoopsClient } from 'loops'

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

// Podstawowe tworzenie kontaktu
async function createBasicContact(email: string, firstName: string) {
  const result = await loops.createContact({
    email,
    firstName,
    source: 'website',
  })

  return result
}

// Pełny kontakt z custom properties
async function createFullContact(userData: {
  email: string
  firstName: string
  lastName: string
  company?: string
  plan?: string
  signupDate?: Date
}) {
  const result = await loops.createContact({
    email: userData.email,
    firstName: userData.firstName,
    lastName: userData.lastName,
    source: 'app-signup',
    userGroup: 'free-trial',

    // Custom properties (muszą być wcześniej zdefiniowane w Loops)
    company: userData.company,
    plan: userData.plan || 'free',
    signupDate: userData.signupDate?.toISOString(),
  })

  return result
}

Aktualizacja kontaktów

Code
TypeScript
// Aktualizacja istniejącego kontaktu
async function updateContact(
  email: string,
  updates: Record<string, any>
) {
  const result = await loops.updateContact({
    email,
    ...updates,
  })

  return result
}

// Przykład: Aktualizacja planu po upgrade
async function handlePlanUpgrade(email: string, newPlan: string) {
  await loops.updateContact({
    email,
    plan: newPlan,
    upgradedAt: new Date().toISOString(),
    userGroup: 'paying-customer',
  })

  // Wyślij event dla automation
  await loops.sendEvent({
    email,
    eventName: 'plan_upgraded',
    eventProperties: {
      newPlan,
      upgradedAt: new Date().toISOString(),
    },
  })
}

Wyszukiwanie i usuwanie kontaktów

Code
TypeScript
// Znajdź kontakt po email
async function findContact(email: string) {
  const contact = await loops.findContact({ email })
  return contact
}

// Usuń kontakt
async function deleteContact(email: string) {
  await loops.deleteContact({ email })
}

// Batch operacje - tworzenie wielu kontaktów
async function importContacts(contacts: Array<{
  email: string
  firstName: string
  source: string
}>) {
  const results = await Promise.allSettled(
    contacts.map(contact => loops.createContact(contact))
  )

  const succeeded = results.filter(r => r.status === 'fulfilled').length
  const failed = results.filter(r => r.status === 'rejected').length

  console.log(`Imported: ${succeeded} succeeded, ${failed} failed`)

  return { succeeded, failed }
}

Transactional Emails

Wysyłanie transactional emails

Transactional emails to emaile wysyłane w odpowiedzi na akcję użytkownika - potwierdzenia, powiadomienia, resetowanie hasła itp.

Code
TypeScript
// Podstawowy transactional email
async function sendWelcomeEmail(
  email: string,
  firstName: string,
  loginUrl: string
) {
  await loops.sendTransactionalEmail({
    transactionalId: 'welcome-email', // ID z Loops dashboard
    email,
    dataVariables: {
      firstName,
      loginUrl,
    },
  })
}

// Email z wieloma zmiennymi
async function sendOrderConfirmation(order: {
  email: string
  orderNumber: string
  items: Array<{ name: string; quantity: number; price: number }>
  total: number
  shippingAddress: string
}) {
  await loops.sendTransactionalEmail({
    transactionalId: 'order-confirmation',
    email: order.email,
    dataVariables: {
      orderNumber: order.orderNumber,
      itemsHtml: order.items
        .map(item => `<li>${item.name} x${item.quantity} - $${item.price}</li>`)
        .join(''),
      total: order.total.toFixed(2),
      shippingAddress: order.shippingAddress,
    },
  })
}

Reset hasła i weryfikacja

Code
TypeScript
// Reset hasła
async function sendPasswordReset(email: string, resetToken: string) {
  const resetUrl = `${process.env.NEXT_PUBLIC_APP_URL}/reset-password?token=${resetToken}`

  await loops.sendTransactionalEmail({
    transactionalId: 'password-reset',
    email,
    dataVariables: {
      resetUrl,
      expiresIn: '24 hours',
    },
  })
}

// Weryfikacja email
async function sendVerificationEmail(
  email: string,
  verificationToken: string
) {
  const verifyUrl = `${process.env.NEXT_PUBLIC_APP_URL}/verify-email?token=${verificationToken}`

  await loops.sendTransactionalEmail({
    transactionalId: 'email-verification',
    email,
    dataVariables: {
      verifyUrl,
    },
  })
}

// Powiadomienie o nowym urządzeniu/logowaniu
async function sendNewLoginAlert(
  email: string,
  loginDetails: {
    device: string
    location: string
    time: Date
    ip: string
  }
) {
  await loops.sendTransactionalEmail({
    transactionalId: 'new-login-alert',
    email,
    dataVariables: {
      device: loginDetails.device,
      location: loginDetails.location,
      time: loginDetails.time.toLocaleString(),
      ip: loginDetails.ip,
    },
  })
}

Attachments

Code
TypeScript
// Email z załącznikiem (base64 encoded)
async function sendInvoiceEmail(
  email: string,
  invoiceNumber: string,
  pdfContent: Buffer
) {
  await loops.sendTransactionalEmail({
    transactionalId: 'invoice',
    email,
    dataVariables: {
      invoiceNumber,
    },
    attachments: [
      {
        filename: `invoice-${invoiceNumber}.pdf`,
        contentType: 'application/pdf',
        data: pdfContent.toString('base64'),
      },
    ],
  })
}

Event-Triggered Campaigns

Wysyłanie events

Events to potężna funkcja Loops pozwalająca triggerować automatyczne kampanie na podstawie zdarzeń w Twojej aplikacji.

Code
TypeScript
// Podstawowy event
async function trackEvent(email: string, eventName: string) {
  await loops.sendEvent({
    email,
    eventName,
  })
}

// Event z properties
async function trackSignup(email: string, signupData: {
  plan: string
  referralSource: string
  utmSource?: string
  utmCampaign?: string
}) {
  await loops.sendEvent({
    email,
    eventName: 'user_signed_up',
    eventProperties: {
      plan: signupData.plan,
      referralSource: signupData.referralSource,
      utmSource: signupData.utmSource || 'direct',
      utmCampaign: signupData.utmCampaign || 'none',
      signedUpAt: new Date().toISOString(),
    },
  })
}

// Event subskrypcji
async function trackSubscription(email: string, subscription: {
  plan: string
  interval: 'monthly' | 'yearly'
  amount: number
  currency: string
}) {
  await loops.sendEvent({
    email,
    eventName: 'subscription_started',
    eventProperties: {
      plan: subscription.plan,
      interval: subscription.interval,
      amount: subscription.amount,
      currency: subscription.currency,
      mrr: subscription.interval === 'yearly'
        ? subscription.amount / 12
        : subscription.amount,
    },
  })
}

Lifecycle events

Code
TypeScript
// Kompletny tracking lifecycle użytkownika
class UserLifecycleTracker {
  constructor(private loops: LoopsClient) {}

  async onSignup(user: { email: string; firstName: string; plan: string }) {
    // Utwórz kontakt
    await this.loops.createContact({
      email: user.email,
      firstName: user.firstName,
      source: 'app',
      userGroup: 'trial',
      plan: user.plan,
    })

    // Wyślij event
    await this.loops.sendEvent({
      email: user.email,
      eventName: 'signed_up',
      eventProperties: { plan: user.plan },
    })
  }

  async onFirstLogin(email: string) {
    await this.loops.sendEvent({
      email,
      eventName: 'first_login',
    })
  }

  async onFeatureUsed(email: string, feature: string) {
    await this.loops.sendEvent({
      email,
      eventName: 'feature_used',
      eventProperties: { feature },
    })
  }

  async onTrialExpiring(email: string, daysLeft: number) {
    await this.loops.sendEvent({
      email,
      eventName: 'trial_expiring',
      eventProperties: { daysLeft },
    })
  }

  async onTrialExpired(email: string) {
    await this.loops.updateContact({
      email,
      userGroup: 'expired-trial',
    })

    await this.loops.sendEvent({
      email,
      eventName: 'trial_expired',
    })
  }

  async onUpgrade(email: string, plan: string, amount: number) {
    await this.loops.updateContact({
      email,
      userGroup: 'paying',
      plan,
    })

    await this.loops.sendEvent({
      email,
      eventName: 'upgraded',
      eventProperties: { plan, amount },
    })
  }

  async onChurn(email: string, reason?: string) {
    await this.loops.updateContact({
      email,
      userGroup: 'churned',
    })

    await this.loops.sendEvent({
      email,
      eventName: 'churned',
      eventProperties: { reason: reason || 'unknown' },
    })
  }
}

Events z Next.js Server Actions

TSapp/actions/loops.ts
TypeScript
// app/actions/loops.ts
'use server'

import { LoopsClient } from 'loops'

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

export async function trackUserAction(
  email: string,
  action: string,
  metadata?: Record<string, any>
) {
  try {
    await loops.sendEvent({
      email,
      eventName: action,
      eventProperties: metadata,
    })
    return { success: true }
  } catch (error) {
    console.error('Failed to track action:', error)
    return { success: false, error: 'Failed to track action' }
  }
}

export async function subscribeToNewsletter(formData: FormData) {
  const email = formData.get('email') as string
  const firstName = formData.get('firstName') as string

  try {
    await loops.createContact({
      email,
      firstName,
      source: 'newsletter',
      userGroup: 'newsletter-subscribers',
    })

    return { success: true }
  } catch (error) {
    console.error('Newsletter signup failed:', error)
    return { success: false, error: 'Signup failed' }
  }
}

Mailing Lists

Zarządzanie listami mailingowymi

Code
TypeScript
// Dodaj do listy
async function addToMailingList(
  email: string,
  listId: string
) {
  await loops.mailingLists.add({
    email,
    mailingListId: listId,
  })
}

// Usuń z listy
async function removeFromMailingList(
  email: string,
  listId: string
) {
  await loops.mailingLists.remove({
    email,
    mailingListId: listId,
  })
}

// Zarządzanie preferencjami newslettera
async function updateNewsletterPreferences(
  email: string,
  preferences: {
    productUpdates: boolean
    weeklyDigest: boolean
    promotions: boolean
    tips: boolean
  }
) {
  const addPromises: Promise<void>[] = []
  const removePromises: Promise<void>[] = []

  const lists = {
    productUpdates: 'list_product_updates',
    weeklyDigest: 'list_weekly_digest',
    promotions: 'list_promotions',
    tips: 'list_tips',
  }

  for (const [key, listId] of Object.entries(lists)) {
    const isSubscribed = preferences[key as keyof typeof preferences]

    if (isSubscribed) {
      addPromises.push(
        loops.mailingLists.add({ email, mailingListId: listId })
      )
    } else {
      removePromises.push(
        loops.mailingLists.remove({ email, mailingListId: listId })
      )
    }
  }

  await Promise.all([...addPromises, ...removePromises])
}

Segmentacja użytkowników

Code
TypeScript
// Segmentacja na podstawie zachowania
async function segmentUserByBehavior(email: string, behavior: {
  lastLoginDays: number
  featuresUsed: number
  planType: string
}) {
  let userGroup: string

  if (behavior.lastLoginDays > 30) {
    userGroup = 'at-risk'
  } else if (behavior.featuresUsed > 10 && behavior.planType === 'free') {
    userGroup = 'upgrade-candidate'
  } else if (behavior.planType !== 'free') {
    userGroup = 'paying-active'
  } else {
    userGroup = 'free-active'
  }

  await loops.updateContact({
    email,
    userGroup,
    lastLoginDays: behavior.lastLoginDays,
    featuresUsed: behavior.featuresUsed,
  })

  // Dodaj do odpowiedniej listy mailingowej
  const listMapping: Record<string, string> = {
    'at-risk': 'list_reengagement',
    'upgrade-candidate': 'list_upgrade_offers',
    'paying-active': 'list_premium_content',
    'free-active': 'list_conversion',
  }

  const listId = listMapping[userGroup]
  if (listId) {
    await loops.mailingLists.add({ email, mailingListId: listId })
  }
}

Webhooks

Konfiguracja webhooks

Loops może wysyłać webhooks przy różnych zdarzeniach - dostarczenie emaila, otwarcie, kliknięcie, bounce itp.

TSapp/api/webhooks/loops/route.ts
TypeScript
// app/api/webhooks/loops/route.ts
import { NextRequest, NextResponse } from 'next/server'
import crypto from 'crypto'

// Typy dla webhook events
interface LoopsWebhookEvent {
  type:
    | 'email.sent'
    | 'email.delivered'
    | 'email.opened'
    | 'email.clicked'
    | 'email.bounced'
    | 'email.complained'
    | 'contact.subscribed'
    | 'contact.unsubscribed'
  data: {
    email: string
    timestamp: string
    transactionalId?: string
    campaignId?: string
    link?: string
    bounceType?: 'hard' | 'soft'
  }
}

// Weryfikacja podpisu webhook
function verifyWebhookSignature(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex')

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  )
}

export async function POST(request: NextRequest) {
  const payload = await request.text()
  const signature = request.headers.get('x-loops-signature') || ''

  // Weryfikuj podpis
  if (!verifyWebhookSignature(
    payload,
    signature,
    process.env.LOOPS_WEBHOOK_SECRET!
  )) {
    return NextResponse.json(
      { error: 'Invalid signature' },
      { status: 401 }
    )
  }

  const event: LoopsWebhookEvent = JSON.parse(payload)

  try {
    switch (event.type) {
      case 'email.delivered':
        await handleEmailDelivered(event.data)
        break

      case 'email.opened':
        await handleEmailOpened(event.data)
        break

      case 'email.clicked':
        await handleEmailClicked(event.data)
        break

      case 'email.bounced':
        await handleEmailBounced(event.data)
        break

      case 'email.complained':
        await handleSpamComplaint(event.data)
        break

      case 'contact.unsubscribed':
        await handleUnsubscribe(event.data)
        break
    }

    return NextResponse.json({ received: true })
  } catch (error) {
    console.error('Webhook processing error:', error)
    return NextResponse.json(
      { error: 'Processing failed' },
      { status: 500 }
    )
  }
}

// Handlery dla różnych eventów
async function handleEmailDelivered(data: LoopsWebhookEvent['data']) {
  // Zapisz w analytics
  await prisma.emailEvent.create({
    data: {
      email: data.email,
      type: 'delivered',
      timestamp: new Date(data.timestamp),
      campaignId: data.campaignId,
    },
  })
}

async function handleEmailOpened(data: LoopsWebhookEvent['data']) {
  await prisma.emailEvent.create({
    data: {
      email: data.email,
      type: 'opened',
      timestamp: new Date(data.timestamp),
      campaignId: data.campaignId,
    },
  })

  // Aktualizuj engagement score użytkownika
  await prisma.user.update({
    where: { email: data.email },
    data: {
      lastEmailOpened: new Date(),
      emailEngagementScore: { increment: 1 },
    },
  })
}

async function handleEmailClicked(data: LoopsWebhookEvent['data']) {
  await prisma.emailEvent.create({
    data: {
      email: data.email,
      type: 'clicked',
      timestamp: new Date(data.timestamp),
      campaignId: data.campaignId,
      link: data.link,
    },
  })

  // Wyższy engagement za kliknięcie
  await prisma.user.update({
    where: { email: data.email },
    data: {
      emailEngagementScore: { increment: 5 },
    },
  })
}

async function handleEmailBounced(data: LoopsWebhookEvent['data']) {
  if (data.bounceType === 'hard') {
    // Hard bounce - oznacz email jako nieprawidłowy
    await prisma.user.update({
      where: { email: data.email },
      data: {
        emailStatus: 'invalid',
        emailInvalidAt: new Date(),
      },
    })
  } else {
    // Soft bounce - śledź, ale nie blokuj
    await prisma.emailBounce.create({
      data: {
        email: data.email,
        type: 'soft',
        timestamp: new Date(data.timestamp),
      },
    })
  }
}

async function handleSpamComplaint(data: LoopsWebhookEvent['data']) {
  // Natychmiastowy unsubscribe przy complaint
  await prisma.user.update({
    where: { email: data.email },
    data: {
      emailStatus: 'complained',
      unsubscribedAt: new Date(),
      unsubscribeReason: 'spam_complaint',
    },
  })
}

async function handleUnsubscribe(data: LoopsWebhookEvent['data']) {
  await prisma.user.update({
    where: { email: data.email },
    data: {
      emailStatus: 'unsubscribed',
      unsubscribedAt: new Date(),
    },
  })
}

Integracja z auth systemami

NextAuth.js integration

TSauth.ts
TypeScript
// auth.ts
import NextAuth from 'next-auth'
import { LoopsClient } from 'loops'

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

export const { handlers, auth, signIn, signOut } = NextAuth({
  // ...other config
  events: {
    async signIn({ user, isNewUser }) {
      if (isNewUser && user.email) {
        // Nowy użytkownik - dodaj do Loops
        await loops.createContact({
          email: user.email,
          firstName: user.name?.split(' ')[0] || '',
          source: 'app-signup',
          userGroup: 'new-users',
        })

        await loops.sendEvent({
          email: user.email,
          eventName: 'signed_up',
        })
      } else if (user.email) {
        // Istniejący użytkownik - track login
        await loops.sendEvent({
          email: user.email,
          eventName: 'logged_in',
        })
      }
    },
  },
})

Clerk integration

TSapp/api/webhooks/clerk/route.ts
TypeScript
// app/api/webhooks/clerk/route.ts
import { Webhook } from 'svix'
import { headers } from 'next/headers'
import { WebhookEvent } from '@clerk/nextjs/server'
import { LoopsClient } from 'loops'

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

export async function POST(req: Request) {
  const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET

  if (!WEBHOOK_SECRET) {
    throw new Error('Missing CLERK_WEBHOOK_SECRET')
  }

  const headerPayload = headers()
  const svix_id = headerPayload.get('svix-id')
  const svix_timestamp = headerPayload.get('svix-timestamp')
  const svix_signature = headerPayload.get('svix-signature')

  const body = await req.text()

  const wh = new Webhook(WEBHOOK_SECRET)
  let evt: WebhookEvent

  try {
    evt = wh.verify(body, {
      'svix-id': svix_id!,
      'svix-timestamp': svix_timestamp!,
      'svix-signature': svix_signature!,
    }) as WebhookEvent
  } catch (err) {
    return new Response('Invalid signature', { status: 400 })
  }

  switch (evt.type) {
    case 'user.created':
      await loops.createContact({
        email: evt.data.email_addresses[0]?.email_address!,
        firstName: evt.data.first_name || '',
        lastName: evt.data.last_name || '',
        source: 'clerk-signup',
        userId: evt.data.id,
      })
      break

    case 'user.updated':
      await loops.updateContact({
        email: evt.data.email_addresses[0]?.email_address!,
        firstName: evt.data.first_name || '',
        lastName: evt.data.last_name || '',
      })
      break

    case 'user.deleted':
      if (evt.data.email_addresses?.[0]) {
        await loops.deleteContact({
          email: evt.data.email_addresses[0].email_address,
        })
      }
      break
  }

  return new Response('OK', { status: 200 })
}

Stripe integration

Synchronizacja z płatnościami Stripe

TSapp/api/webhooks/stripe/route.ts
TypeScript
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { LoopsClient } from 'loops'

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

export async function POST(request: NextRequest) {
  const body = await request.text()
  const signature = request.headers.get('stripe-signature')!

  let event: Stripe.Event

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    return NextResponse.json(
      { error: 'Invalid signature' },
      { status: 400 }
    )
  }

  switch (event.type) {
    case 'customer.subscription.created': {
      const subscription = event.data.object as Stripe.Subscription
      const customer = await stripe.customers.retrieve(
        subscription.customer as string
      ) as Stripe.Customer

      if (customer.email) {
        await loops.updateContact({
          email: customer.email,
          userGroup: 'paying',
          plan: subscription.items.data[0]?.price.nickname || 'unknown',
          subscriptionStatus: subscription.status,
        })

        await loops.sendEvent({
          email: customer.email,
          eventName: 'subscription_created',
          eventProperties: {
            plan: subscription.items.data[0]?.price.nickname,
            amount: subscription.items.data[0]?.price.unit_amount! / 100,
            interval: subscription.items.data[0]?.price.recurring?.interval,
          },
        })
      }
      break
    }

    case 'customer.subscription.updated': {
      const subscription = event.data.object as Stripe.Subscription
      const customer = await stripe.customers.retrieve(
        subscription.customer as string
      ) as Stripe.Customer

      if (customer.email) {
        await loops.updateContact({
          email: customer.email,
          plan: subscription.items.data[0]?.price.nickname || 'unknown',
          subscriptionStatus: subscription.status,
        })

        // Sprawdź czy to upgrade
        const previousPlan = event.data.previous_attributes?.items?.data[0]
          ?.price?.unit_amount
        const currentPlan = subscription.items.data[0]?.price.unit_amount

        if (previousPlan && currentPlan && currentPlan > previousPlan) {
          await loops.sendEvent({
            email: customer.email,
            eventName: 'subscription_upgraded',
            eventProperties: {
              newPlan: subscription.items.data[0]?.price.nickname,
              newAmount: currentPlan / 100,
            },
          })
        }
      }
      break
    }

    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription
      const customer = await stripe.customers.retrieve(
        subscription.customer as string
      ) as Stripe.Customer

      if (customer.email) {
        await loops.updateContact({
          email: customer.email,
          userGroup: 'churned',
          subscriptionStatus: 'canceled',
          churnedAt: new Date().toISOString(),
        })

        await loops.sendEvent({
          email: customer.email,
          eventName: 'subscription_canceled',
        })
      }
      break
    }

    case 'invoice.payment_failed': {
      const invoice = event.data.object as Stripe.Invoice

      if (invoice.customer_email) {
        await loops.sendEvent({
          email: invoice.customer_email,
          eventName: 'payment_failed',
          eventProperties: {
            amount: invoice.amount_due / 100,
            attemptCount: invoice.attempt_count,
          },
        })
      }
      break
    }
  }

  return NextResponse.json({ received: true })
}

Best practices

Error handling

Code
TypeScript
import { LoopsClient } from 'loops'

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

// Wrapper z retry logic
async function withRetry<T>(
  operation: () => Promise<T>,
  maxRetries = 3,
  delay = 1000
): Promise<T> {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await operation()
    } catch (error) {
      if (attempt === maxRetries) {
        throw error
      }

      console.warn(`Attempt ${attempt} failed, retrying in ${delay}ms...`)
      await new Promise(resolve => setTimeout(resolve, delay))
      delay *= 2 // Exponential backoff
    }
  }

  throw new Error('Max retries exceeded')
}

// Bezpieczne wysyłanie emaili
async function safeSendTransactional(
  transactionalId: string,
  email: string,
  dataVariables: Record<string, any>
) {
  try {
    await withRetry(() =>
      loops.sendTransactionalEmail({
        transactionalId,
        email,
        dataVariables,
      })
    )
    return { success: true }
  } catch (error) {
    console.error('Failed to send email:', error)

    // Zapisz do kolejki do ponowienia później
    await queueFailedEmail({
      type: 'transactional',
      transactionalId,
      email,
      dataVariables,
      error: error instanceof Error ? error.message : 'Unknown error',
    })

    return { success: false, error }
  }
}

Rate limiting

TSlib/loops-rate-limiter.ts
TypeScript
// lib/loops-rate-limiter.ts
import { RateLimiter } from 'limiter'

// Loops ma limit 100 requests/second
const limiter = new RateLimiter({
  tokensPerInterval: 100,
  interval: 'second',
})

export async function rateLimitedLoopsCall<T>(
  operation: () => Promise<T>
): Promise<T> {
  await limiter.removeTokens(1)
  return operation()
}

// Użycie
async function sendBulkEvents(events: Array<{
  email: string
  eventName: string
  eventProperties?: Record<string, any>
}>) {
  const results = await Promise.allSettled(
    events.map(event =>
      rateLimitedLoopsCall(() =>
        loops.sendEvent({
          email: event.email,
          eventName: event.eventName,
          eventProperties: event.eventProperties,
        })
      )
    )
  )

  return results
}

Queue dla niezawodności

TSlib/email-queue.ts
TypeScript
// lib/email-queue.ts
import { Queue } from 'bullmq'
import { LoopsClient } from 'loops'

const emailQueue = new Queue('email', {
  connection: {
    host: process.env.REDIS_HOST,
    port: parseInt(process.env.REDIS_PORT!),
  },
})

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

// Dodaj email do kolejki
export async function queueTransactionalEmail(
  transactionalId: string,
  email: string,
  dataVariables: Record<string, any>,
  priority: 'high' | 'normal' | 'low' = 'normal'
) {
  const priorityMap = { high: 1, normal: 2, low: 3 }

  await emailQueue.add(
    'send-transactional',
    { transactionalId, email, dataVariables },
    {
      priority: priorityMap[priority],
      attempts: 3,
      backoff: {
        type: 'exponential',
        delay: 1000,
      },
    }
  )
}

// Worker do przetwarzania kolejki
// workers/email.worker.ts
import { Worker } from 'bullmq'

const worker = new Worker(
  'email',
  async job => {
    const { transactionalId, email, dataVariables } = job.data

    await loops.sendTransactionalEmail({
      transactionalId,
      email,
      dataVariables,
    })

    console.log(`Email sent: ${transactionalId} to ${email}`)
  },
  {
    connection: {
      host: process.env.REDIS_HOST,
      port: parseInt(process.env.REDIS_PORT!),
    },
    limiter: {
      max: 100,
      duration: 1000, // 100 per second
    },
  }
)

worker.on('failed', (job, err) => {
  console.error(`Job ${job?.id} failed:`, err)
})

Testowanie

Unit tests

TS__tests__/loops.test.ts
TypeScript
// __tests__/loops.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { LoopsClient } from 'loops'

// Mock Loops client
vi.mock('loops', () => ({
  LoopsClient: vi.fn().mockImplementation(() => ({
    createContact: vi.fn().mockResolvedValue({ success: true }),
    sendEvent: vi.fn().mockResolvedValue({ success: true }),
    sendTransactionalEmail: vi.fn().mockResolvedValue({ success: true }),
  })),
}))

describe('Loops Integration', () => {
  let loops: LoopsClient

  beforeEach(() => {
    loops = new LoopsClient('test-api-key')
  })

  it('should create contact with correct data', async () => {
    await loops.createContact({
      email: 'test@example.com',
      firstName: 'Test',
      source: 'test',
    })

    expect(loops.createContact).toHaveBeenCalledWith({
      email: 'test@example.com',
      firstName: 'Test',
      source: 'test',
    })
  })

  it('should send event with properties', async () => {
    await loops.sendEvent({
      email: 'test@example.com',
      eventName: 'test_event',
      eventProperties: { key: 'value' },
    })

    expect(loops.sendEvent).toHaveBeenCalledWith({
      email: 'test@example.com',
      eventName: 'test_event',
      eventProperties: { key: 'value' },
    })
  })

  it('should send transactional email', async () => {
    await loops.sendTransactionalEmail({
      transactionalId: 'welcome',
      email: 'test@example.com',
      dataVariables: { name: 'Test' },
    })

    expect(loops.sendTransactionalEmail).toHaveBeenCalledWith({
      transactionalId: 'welcome',
      email: 'test@example.com',
      dataVariables: { name: 'Test' },
    })
  })
})

Integration tests z MSW

TS__tests__/loops-integration.test.ts
TypeScript
// __tests__/loops-integration.test.ts
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
import { LoopsClient } from 'loops'

const server = setupServer(
  http.post('https://app.loops.so/api/v1/contacts/create', () => {
    return HttpResponse.json({ success: true, id: 'contact_123' })
  }),

  http.post('https://app.loops.so/api/v1/events/send', () => {
    return HttpResponse.json({ success: true })
  }),

  http.post('https://app.loops.so/api/v1/transactional', () => {
    return HttpResponse.json({ success: true })
  })
)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

describe('Loops API Integration', () => {
  const loops = new LoopsClient('test-api-key')

  it('handles API errors gracefully', async () => {
    server.use(
      http.post('https://app.loops.so/api/v1/contacts/create', () => {
        return new HttpResponse(null, { status: 500 })
      })
    )

    await expect(
      loops.createContact({ email: 'test@example.com' })
    ).rejects.toThrow()
  })
})

Cennik

Plany Loops

PlanCenaKontaktyFunkcje
Free$0/mo1,000Wszystkie funkcje, unlimited emails
Starter$49/mo5,000Wszystkie funkcje, priority support
Growth$99/mo10,000Wszystkie funkcje, dedicated support
Pro$249/mo50,000Custom SLA, API priority
EnterpriseCustomUnlimitedDedicated infrastructure

Kluczowe informacje o pricingu

  • Płacisz za kontakty, nie emaile - Unlimited sending na każdym planie
  • Brak ukrytych opłat - Wszystkie funkcje dostępne od razu
  • Free tier bez ograniczeń czasowych - Nie trial, tylko mniejszy limit kontaktów
  • Yearly discount - 20% zniżki przy płatności rocznej

FAQ - Najczęściej zadawane pytania

Czy Loops jest dobry dla małych startupów?

Tak, Loops jest idealny dla startupów. Free tier (1,000 kontaktów) wystarcza na początek, a prosty API i UI pozwala szybko zintegrować bez dedykowanego DevOps.

Czym Loops różni się od Mailchimp?

Loops jest zaprojektowany specjalnie dla SaaS:

  • Event-driven automation zamiast list-based
  • Nowoczesne API (REST) vs legacy
  • Prostszy UI bez zbędnych funkcji
  • Transactional i marketing w jednym
  • Sensowny pricing dla startupów

Czy mogę migrować z innego narzędzia?

Tak, Loops oferuje import kontaktów z CSV. Możesz też użyć API do programatycznej migracji. Zespół Loops pomaga w migracji na planach Growth+.

Jak działa deliverability w Loops?

Loops ma własną infrastrukturę email z:

  • Dedicated IP addresses
  • DKIM/SPF/DMARC konfiguracja
  • Automatic warmup
  • Reputation monitoring
  • Deliverability ~99%

Czy Loops obsługuje A/B testing?

Tak, Loops oferuje A/B testing dla:

  • Subject lines
  • Email content
  • Send times
  • Sender names

Czy mogę używać własnych szablonów HTML?

Tak, Loops wspiera:

  • Własne HTML templates
  • Drag & drop editor
  • Plain text emails
  • Dynamic content z variables

Loops - email marketing for SaaS

What is Loops?

Loops is a modern email marketing platform designed from the ground up with SaaS (Software as a Service) companies in mind. Unlike traditional email marketing tools that try to serve every type of business, Loops focuses exclusively on the needs of software products - from transactional emails through onboarding sequences to product updates and lifecycle campaigns.

Loops was founded in 2022 by Chris Frantz (a former Stripe engineer) with the vision of creating "the Stripe of email marketing" - a platform that is just as simple to integrate as Stripe is for payments. The company quickly gained recognition in the startup community thanks to its intuitive interface, modern API, and transparent pricing.

Why Loops?

Key advantages of Loops:

  1. Designed for SaaS - It is not yet another tool "for everyone" - it is precisely tailored to the needs of software companies
  2. Simple, modern API - Integration in minutes instead of days
  3. Event-driven automation - Trigger emails based on events in your application
  4. Unified platform - Transactional, marketing, and automated emails in one place
  5. Developer-friendly - TypeScript SDK, webhooks, excellent documentation
  6. Sensible pricing - You pay for contacts, not for emails sent

Loops vs alternatives

FeatureLoopsMailchimpSendGridCustomer.io
Main focusSaaSEveryoneDevelopersEnterprise
Ease of use⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
APIModern RESTLegacyGoodGood
Sequences✅ Built-in✅ Complex❌ None✅ Advanced
Transactional✅ Built-in⚠️ Separate service✅ Core feature✅ Built-in
Event triggers✅ Simple⚠️ Complex❌ None✅ Advanced
Price (5K contacts)$49/mo$59/mo$19.95/mo$150/mo
Free tier1,000 contacts500 contacts100 emails/day14-day trial

Installation and configuration

Installing the SDK

Code
Bash
# npm
npm install loops

# yarn
yarn add loops

# pnpm
pnpm add loops

Basic configuration

TSlib/loops.ts
TypeScript
// lib/loops.ts
import { LoopsClient } from 'loops'

let loopsClient: LoopsClient | null = null

export function getLoopsClient(): LoopsClient {
  if (!loopsClient) {
    const apiKey = process.env.LOOPS_API_KEY

    if (!apiKey) {
      throw new Error('LOOPS_API_KEY is required')
    }

    loopsClient = new LoopsClient(apiKey)
  }

  return loopsClient
}

export const loops = getLoopsClient()

Configuration in Next.js

TSapp/api/loops/route.ts
TypeScript
// app/api/loops/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { LoopsClient } from 'loops'

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

export async function POST(request: NextRequest) {
  try {
    const { action, ...data } = await request.json()

    switch (action) {
      case 'createContact':
        const contact = await loops.createContact(data)
        return NextResponse.json({ success: true, contact })

      case 'sendEvent':
        await loops.sendEvent(data)
        return NextResponse.json({ success: true })

      case 'sendTransactional':
        await loops.sendTransactionalEmail(data)
        return NextResponse.json({ success: true })

      default:
        return NextResponse.json(
          { error: 'Unknown action' },
          { status: 400 }
        )
    }
  } catch (error) {
    console.error('Loops API error:', error)
    return NextResponse.json(
      { error: 'Failed to process request' },
      { status: 500 }
    )
  }
}

Environment variables

.env.local
Bash
# .env.local
LOOPS_API_KEY=your_api_key_here

# Optional - custom endpoint for self-hosted
LOOPS_API_URL=https://app.loops.so/api/v1

Contact management

Creating contacts

Code
TypeScript
import { LoopsClient } from 'loops'

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

async function createBasicContact(email: string, firstName: string) {
  const result = await loops.createContact({
    email,
    firstName,
    source: 'website',
  })

  return result
}

async function createFullContact(userData: {
  email: string
  firstName: string
  lastName: string
  company?: string
  plan?: string
  signupDate?: Date
}) {
  const result = await loops.createContact({
    email: userData.email,
    firstName: userData.firstName,
    lastName: userData.lastName,
    source: 'app-signup',
    userGroup: 'free-trial',

    company: userData.company,
    plan: userData.plan || 'free',
    signupDate: userData.signupDate?.toISOString(),
  })

  return result
}

Updating contacts

Code
TypeScript
async function updateContact(
  email: string,
  updates: Record<string, any>
) {
  const result = await loops.updateContact({
    email,
    ...updates,
  })

  return result
}

async function handlePlanUpgrade(email: string, newPlan: string) {
  await loops.updateContact({
    email,
    plan: newPlan,
    upgradedAt: new Date().toISOString(),
    userGroup: 'paying-customer',
  })

  await loops.sendEvent({
    email,
    eventName: 'plan_upgraded',
    eventProperties: {
      newPlan,
      upgradedAt: new Date().toISOString(),
    },
  })
}

Searching and deleting contacts

Code
TypeScript
async function findContact(email: string) {
  const contact = await loops.findContact({ email })
  return contact
}

async function deleteContact(email: string) {
  await loops.deleteContact({ email })
}

async function importContacts(contacts: Array<{
  email: string
  firstName: string
  source: string
}>) {
  const results = await Promise.allSettled(
    contacts.map(contact => loops.createContact(contact))
  )

  const succeeded = results.filter(r => r.status === 'fulfilled').length
  const failed = results.filter(r => r.status === 'rejected').length

  console.log(`Imported: ${succeeded} succeeded, ${failed} failed`)

  return { succeeded, failed }
}

Transactional emails

Sending transactional emails

Transactional emails are emails sent in response to a user action - confirmations, notifications, password resets, and so on.

Code
TypeScript
async function sendWelcomeEmail(
  email: string,
  firstName: string,
  loginUrl: string
) {
  await loops.sendTransactionalEmail({
    transactionalId: 'welcome-email',
    email,
    dataVariables: {
      firstName,
      loginUrl,
    },
  })
}

async function sendOrderConfirmation(order: {
  email: string
  orderNumber: string
  items: Array<{ name: string; quantity: number; price: number }>
  total: number
  shippingAddress: string
}) {
  await loops.sendTransactionalEmail({
    transactionalId: 'order-confirmation',
    email: order.email,
    dataVariables: {
      orderNumber: order.orderNumber,
      itemsHtml: order.items
        .map(item => `<li>${item.name} x${item.quantity} - $${item.price}</li>`)
        .join(''),
      total: order.total.toFixed(2),
      shippingAddress: order.shippingAddress,
    },
  })
}

Password reset and verification

Code
TypeScript
async function sendPasswordReset(email: string, resetToken: string) {
  const resetUrl = `${process.env.NEXT_PUBLIC_APP_URL}/reset-password?token=${resetToken}`

  await loops.sendTransactionalEmail({
    transactionalId: 'password-reset',
    email,
    dataVariables: {
      resetUrl,
      expiresIn: '24 hours',
    },
  })
}

async function sendVerificationEmail(
  email: string,
  verificationToken: string
) {
  const verifyUrl = `${process.env.NEXT_PUBLIC_APP_URL}/verify-email?token=${verificationToken}`

  await loops.sendTransactionalEmail({
    transactionalId: 'email-verification',
    email,
    dataVariables: {
      verifyUrl,
    },
  })
}

async function sendNewLoginAlert(
  email: string,
  loginDetails: {
    device: string
    location: string
    time: Date
    ip: string
  }
) {
  await loops.sendTransactionalEmail({
    transactionalId: 'new-login-alert',
    email,
    dataVariables: {
      device: loginDetails.device,
      location: loginDetails.location,
      time: loginDetails.time.toLocaleString(),
      ip: loginDetails.ip,
    },
  })
}

Attachments

Code
TypeScript
async function sendInvoiceEmail(
  email: string,
  invoiceNumber: string,
  pdfContent: Buffer
) {
  await loops.sendTransactionalEmail({
    transactionalId: 'invoice',
    email,
    dataVariables: {
      invoiceNumber,
    },
    attachments: [
      {
        filename: `invoice-${invoiceNumber}.pdf`,
        contentType: 'application/pdf',
        data: pdfContent.toString('base64'),
      },
    ],
  })
}

Event-triggered campaigns

Sending events

Events are a powerful Loops feature that lets you trigger automated campaigns based on events happening in your application.

Code
TypeScript
async function trackEvent(email: string, eventName: string) {
  await loops.sendEvent({
    email,
    eventName,
  })
}

async function trackSignup(email: string, signupData: {
  plan: string
  referralSource: string
  utmSource?: string
  utmCampaign?: string
}) {
  await loops.sendEvent({
    email,
    eventName: 'user_signed_up',
    eventProperties: {
      plan: signupData.plan,
      referralSource: signupData.referralSource,
      utmSource: signupData.utmSource || 'direct',
      utmCampaign: signupData.utmCampaign || 'none',
      signedUpAt: new Date().toISOString(),
    },
  })
}

async function trackSubscription(email: string, subscription: {
  plan: string
  interval: 'monthly' | 'yearly'
  amount: number
  currency: string
}) {
  await loops.sendEvent({
    email,
    eventName: 'subscription_started',
    eventProperties: {
      plan: subscription.plan,
      interval: subscription.interval,
      amount: subscription.amount,
      currency: subscription.currency,
      mrr: subscription.interval === 'yearly'
        ? subscription.amount / 12
        : subscription.amount,
    },
  })
}

Lifecycle events

Code
TypeScript
class UserLifecycleTracker {
  constructor(private loops: LoopsClient) {}

  async onSignup(user: { email: string; firstName: string; plan: string }) {
    await this.loops.createContact({
      email: user.email,
      firstName: user.firstName,
      source: 'app',
      userGroup: 'trial',
      plan: user.plan,
    })

    await this.loops.sendEvent({
      email: user.email,
      eventName: 'signed_up',
      eventProperties: { plan: user.plan },
    })
  }

  async onFirstLogin(email: string) {
    await this.loops.sendEvent({
      email,
      eventName: 'first_login',
    })
  }

  async onFeatureUsed(email: string, feature: string) {
    await this.loops.sendEvent({
      email,
      eventName: 'feature_used',
      eventProperties: { feature },
    })
  }

  async onTrialExpiring(email: string, daysLeft: number) {
    await this.loops.sendEvent({
      email,
      eventName: 'trial_expiring',
      eventProperties: { daysLeft },
    })
  }

  async onTrialExpired(email: string) {
    await this.loops.updateContact({
      email,
      userGroup: 'expired-trial',
    })

    await this.loops.sendEvent({
      email,
      eventName: 'trial_expired',
    })
  }

  async onUpgrade(email: string, plan: string, amount: number) {
    await this.loops.updateContact({
      email,
      userGroup: 'paying',
      plan,
    })

    await this.loops.sendEvent({
      email,
      eventName: 'upgraded',
      eventProperties: { plan, amount },
    })
  }

  async onChurn(email: string, reason?: string) {
    await this.loops.updateContact({
      email,
      userGroup: 'churned',
    })

    await this.loops.sendEvent({
      email,
      eventName: 'churned',
      eventProperties: { reason: reason || 'unknown' },
    })
  }
}

Events with Next.js Server Actions

TSapp/actions/loops.ts
TypeScript
// app/actions/loops.ts
'use server'

import { LoopsClient } from 'loops'

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

export async function trackUserAction(
  email: string,
  action: string,
  metadata?: Record<string, any>
) {
  try {
    await loops.sendEvent({
      email,
      eventName: action,
      eventProperties: metadata,
    })
    return { success: true }
  } catch (error) {
    console.error('Failed to track action:', error)
    return { success: false, error: 'Failed to track action' }
  }
}

export async function subscribeToNewsletter(formData: FormData) {
  const email = formData.get('email') as string
  const firstName = formData.get('firstName') as string

  try {
    await loops.createContact({
      email,
      firstName,
      source: 'newsletter',
      userGroup: 'newsletter-subscribers',
    })

    return { success: true }
  } catch (error) {
    console.error('Newsletter signup failed:', error)
    return { success: false, error: 'Signup failed' }
  }
}

Mailing lists

Managing mailing lists

Code
TypeScript
async function addToMailingList(
  email: string,
  listId: string
) {
  await loops.mailingLists.add({
    email,
    mailingListId: listId,
  })
}

async function removeFromMailingList(
  email: string,
  listId: string
) {
  await loops.mailingLists.remove({
    email,
    mailingListId: listId,
  })
}

async function updateNewsletterPreferences(
  email: string,
  preferences: {
    productUpdates: boolean
    weeklyDigest: boolean
    promotions: boolean
    tips: boolean
  }
) {
  const addPromises: Promise<void>[] = []
  const removePromises: Promise<void>[] = []

  const lists = {
    productUpdates: 'list_product_updates',
    weeklyDigest: 'list_weekly_digest',
    promotions: 'list_promotions',
    tips: 'list_tips',
  }

  for (const [key, listId] of Object.entries(lists)) {
    const isSubscribed = preferences[key as keyof typeof preferences]

    if (isSubscribed) {
      addPromises.push(
        loops.mailingLists.add({ email, mailingListId: listId })
      )
    } else {
      removePromises.push(
        loops.mailingLists.remove({ email, mailingListId: listId })
      )
    }
  }

  await Promise.all([...addPromises, ...removePromises])
}

User segmentation

Code
TypeScript
async function segmentUserByBehavior(email: string, behavior: {
  lastLoginDays: number
  featuresUsed: number
  planType: string
}) {
  let userGroup: string

  if (behavior.lastLoginDays > 30) {
    userGroup = 'at-risk'
  } else if (behavior.featuresUsed > 10 && behavior.planType === 'free') {
    userGroup = 'upgrade-candidate'
  } else if (behavior.planType !== 'free') {
    userGroup = 'paying-active'
  } else {
    userGroup = 'free-active'
  }

  await loops.updateContact({
    email,
    userGroup,
    lastLoginDays: behavior.lastLoginDays,
    featuresUsed: behavior.featuresUsed,
  })

  const listMapping: Record<string, string> = {
    'at-risk': 'list_reengagement',
    'upgrade-candidate': 'list_upgrade_offers',
    'paying-active': 'list_premium_content',
    'free-active': 'list_conversion',
  }

  const listId = listMapping[userGroup]
  if (listId) {
    await loops.mailingLists.add({ email, mailingListId: listId })
  }
}

Webhooks

Configuring webhooks

Loops can send webhooks for various events - email delivery, opens, clicks, bounces, and more.

TSapp/api/webhooks/loops/route.ts
TypeScript
// app/api/webhooks/loops/route.ts
import { NextRequest, NextResponse } from 'next/server'
import crypto from 'crypto'

interface LoopsWebhookEvent {
  type:
    | 'email.sent'
    | 'email.delivered'
    | 'email.opened'
    | 'email.clicked'
    | 'email.bounced'
    | 'email.complained'
    | 'contact.subscribed'
    | 'contact.unsubscribed'
  data: {
    email: string
    timestamp: string
    transactionalId?: string
    campaignId?: string
    link?: string
    bounceType?: 'hard' | 'soft'
  }
}

function verifyWebhookSignature(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex')

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  )
}

export async function POST(request: NextRequest) {
  const payload = await request.text()
  const signature = request.headers.get('x-loops-signature') || ''

  if (!verifyWebhookSignature(
    payload,
    signature,
    process.env.LOOPS_WEBHOOK_SECRET!
  )) {
    return NextResponse.json(
      { error: 'Invalid signature' },
      { status: 401 }
    )
  }

  const event: LoopsWebhookEvent = JSON.parse(payload)

  try {
    switch (event.type) {
      case 'email.delivered':
        await handleEmailDelivered(event.data)
        break

      case 'email.opened':
        await handleEmailOpened(event.data)
        break

      case 'email.clicked':
        await handleEmailClicked(event.data)
        break

      case 'email.bounced':
        await handleEmailBounced(event.data)
        break

      case 'email.complained':
        await handleSpamComplaint(event.data)
        break

      case 'contact.unsubscribed':
        await handleUnsubscribe(event.data)
        break
    }

    return NextResponse.json({ received: true })
  } catch (error) {
    console.error('Webhook processing error:', error)
    return NextResponse.json(
      { error: 'Processing failed' },
      { status: 500 }
    )
  }
}

async function handleEmailDelivered(data: LoopsWebhookEvent['data']) {
  await prisma.emailEvent.create({
    data: {
      email: data.email,
      type: 'delivered',
      timestamp: new Date(data.timestamp),
      campaignId: data.campaignId,
    },
  })
}

async function handleEmailOpened(data: LoopsWebhookEvent['data']) {
  await prisma.emailEvent.create({
    data: {
      email: data.email,
      type: 'opened',
      timestamp: new Date(data.timestamp),
      campaignId: data.campaignId,
    },
  })

  await prisma.user.update({
    where: { email: data.email },
    data: {
      lastEmailOpened: new Date(),
      emailEngagementScore: { increment: 1 },
    },
  })
}

async function handleEmailClicked(data: LoopsWebhookEvent['data']) {
  await prisma.emailEvent.create({
    data: {
      email: data.email,
      type: 'clicked',
      timestamp: new Date(data.timestamp),
      campaignId: data.campaignId,
      link: data.link,
    },
  })

  await prisma.user.update({
    where: { email: data.email },
    data: {
      emailEngagementScore: { increment: 5 },
    },
  })
}

async function handleEmailBounced(data: LoopsWebhookEvent['data']) {
  if (data.bounceType === 'hard') {
    await prisma.user.update({
      where: { email: data.email },
      data: {
        emailStatus: 'invalid',
        emailInvalidAt: new Date(),
      },
    })
  } else {
    await prisma.emailBounce.create({
      data: {
        email: data.email,
        type: 'soft',
        timestamp: new Date(data.timestamp),
      },
    })
  }
}

async function handleSpamComplaint(data: LoopsWebhookEvent['data']) {
  await prisma.user.update({
    where: { email: data.email },
    data: {
      emailStatus: 'complained',
      unsubscribedAt: new Date(),
      unsubscribeReason: 'spam_complaint',
    },
  })
}

async function handleUnsubscribe(data: LoopsWebhookEvent['data']) {
  await prisma.user.update({
    where: { email: data.email },
    data: {
      emailStatus: 'unsubscribed',
      unsubscribedAt: new Date(),
    },
  })
}

Integration with auth systems

NextAuth.js integration

TSauth.ts
TypeScript
// auth.ts
import NextAuth from 'next-auth'
import { LoopsClient } from 'loops'

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

export const { handlers, auth, signIn, signOut } = NextAuth({
  // ...other config
  events: {
    async signIn({ user, isNewUser }) {
      if (isNewUser && user.email) {
        await loops.createContact({
          email: user.email,
          firstName: user.name?.split(' ')[0] || '',
          source: 'app-signup',
          userGroup: 'new-users',
        })

        await loops.sendEvent({
          email: user.email,
          eventName: 'signed_up',
        })
      } else if (user.email) {
        await loops.sendEvent({
          email: user.email,
          eventName: 'logged_in',
        })
      }
    },
  },
})

Clerk integration

TSapp/api/webhooks/clerk/route.ts
TypeScript
// app/api/webhooks/clerk/route.ts
import { Webhook } from 'svix'
import { headers } from 'next/headers'
import { WebhookEvent } from '@clerk/nextjs/server'
import { LoopsClient } from 'loops'

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

export async function POST(req: Request) {
  const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET

  if (!WEBHOOK_SECRET) {
    throw new Error('Missing CLERK_WEBHOOK_SECRET')
  }

  const headerPayload = headers()
  const svix_id = headerPayload.get('svix-id')
  const svix_timestamp = headerPayload.get('svix-timestamp')
  const svix_signature = headerPayload.get('svix-signature')

  const body = await req.text()

  const wh = new Webhook(WEBHOOK_SECRET)
  let evt: WebhookEvent

  try {
    evt = wh.verify(body, {
      'svix-id': svix_id!,
      'svix-timestamp': svix_timestamp!,
      'svix-signature': svix_signature!,
    }) as WebhookEvent
  } catch (err) {
    return new Response('Invalid signature', { status: 400 })
  }

  switch (evt.type) {
    case 'user.created':
      await loops.createContact({
        email: evt.data.email_addresses[0]?.email_address!,
        firstName: evt.data.first_name || '',
        lastName: evt.data.last_name || '',
        source: 'clerk-signup',
        userId: evt.data.id,
      })
      break

    case 'user.updated':
      await loops.updateContact({
        email: evt.data.email_addresses[0]?.email_address!,
        firstName: evt.data.first_name || '',
        lastName: evt.data.last_name || '',
      })
      break

    case 'user.deleted':
      if (evt.data.email_addresses?.[0]) {
        await loops.deleteContact({
          email: evt.data.email_addresses[0].email_address,
        })
      }
      break
  }

  return new Response('OK', { status: 200 })
}

Stripe integration

Synchronizing with Stripe payments

TSapp/api/webhooks/stripe/route.ts
TypeScript
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { LoopsClient } from 'loops'

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

export async function POST(request: NextRequest) {
  const body = await request.text()
  const signature = request.headers.get('stripe-signature')!

  let event: Stripe.Event

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    return NextResponse.json(
      { error: 'Invalid signature' },
      { status: 400 }
    )
  }

  switch (event.type) {
    case 'customer.subscription.created': {
      const subscription = event.data.object as Stripe.Subscription
      const customer = await stripe.customers.retrieve(
        subscription.customer as string
      ) as Stripe.Customer

      if (customer.email) {
        await loops.updateContact({
          email: customer.email,
          userGroup: 'paying',
          plan: subscription.items.data[0]?.price.nickname || 'unknown',
          subscriptionStatus: subscription.status,
        })

        await loops.sendEvent({
          email: customer.email,
          eventName: 'subscription_created',
          eventProperties: {
            plan: subscription.items.data[0]?.price.nickname,
            amount: subscription.items.data[0]?.price.unit_amount! / 100,
            interval: subscription.items.data[0]?.price.recurring?.interval,
          },
        })
      }
      break
    }

    case 'customer.subscription.updated': {
      const subscription = event.data.object as Stripe.Subscription
      const customer = await stripe.customers.retrieve(
        subscription.customer as string
      ) as Stripe.Customer

      if (customer.email) {
        await loops.updateContact({
          email: customer.email,
          plan: subscription.items.data[0]?.price.nickname || 'unknown',
          subscriptionStatus: subscription.status,
        })

        const previousPlan = event.data.previous_attributes?.items?.data[0]
          ?.price?.unit_amount
        const currentPlan = subscription.items.data[0]?.price.unit_amount

        if (previousPlan && currentPlan && currentPlan > previousPlan) {
          await loops.sendEvent({
            email: customer.email,
            eventName: 'subscription_upgraded',
            eventProperties: {
              newPlan: subscription.items.data[0]?.price.nickname,
              newAmount: currentPlan / 100,
            },
          })
        }
      }
      break
    }

    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription
      const customer = await stripe.customers.retrieve(
        subscription.customer as string
      ) as Stripe.Customer

      if (customer.email) {
        await loops.updateContact({
          email: customer.email,
          userGroup: 'churned',
          subscriptionStatus: 'canceled',
          churnedAt: new Date().toISOString(),
        })

        await loops.sendEvent({
          email: customer.email,
          eventName: 'subscription_canceled',
        })
      }
      break
    }

    case 'invoice.payment_failed': {
      const invoice = event.data.object as Stripe.Invoice

      if (invoice.customer_email) {
        await loops.sendEvent({
          email: invoice.customer_email,
          eventName: 'payment_failed',
          eventProperties: {
            amount: invoice.amount_due / 100,
            attemptCount: invoice.attempt_count,
          },
        })
      }
      break
    }
  }

  return NextResponse.json({ received: true })
}

Best practices

Error handling

Code
TypeScript
import { LoopsClient } from 'loops'

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

async function withRetry<T>(
  operation: () => Promise<T>,
  maxRetries = 3,
  delay = 1000
): Promise<T> {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await operation()
    } catch (error) {
      if (attempt === maxRetries) {
        throw error
      }

      console.warn(`Attempt ${attempt} failed, retrying in ${delay}ms...`)
      await new Promise(resolve => setTimeout(resolve, delay))
      delay *= 2 // Exponential backoff
    }
  }

  throw new Error('Max retries exceeded')
}

async function safeSendTransactional(
  transactionalId: string,
  email: string,
  dataVariables: Record<string, any>
) {
  try {
    await withRetry(() =>
      loops.sendTransactionalEmail({
        transactionalId,
        email,
        dataVariables,
      })
    )
    return { success: true }
  } catch (error) {
    console.error('Failed to send email:', error)

    await queueFailedEmail({
      type: 'transactional',
      transactionalId,
      email,
      dataVariables,
      error: error instanceof Error ? error.message : 'Unknown error',
    })

    return { success: false, error }
  }
}

Rate limiting

TSlib/loops-rate-limiter.ts
TypeScript
// lib/loops-rate-limiter.ts
import { RateLimiter } from 'limiter'

const limiter = new RateLimiter({
  tokensPerInterval: 100,
  interval: 'second',
})

export async function rateLimitedLoopsCall<T>(
  operation: () => Promise<T>
): Promise<T> {
  await limiter.removeTokens(1)
  return operation()
}

async function sendBulkEvents(events: Array<{
  email: string
  eventName: string
  eventProperties?: Record<string, any>
}>) {
  const results = await Promise.allSettled(
    events.map(event =>
      rateLimitedLoopsCall(() =>
        loops.sendEvent({
          email: event.email,
          eventName: event.eventName,
          eventProperties: event.eventProperties,
        })
      )
    )
  )

  return results
}

Queue for reliability

TSlib/email-queue.ts
TypeScript
// lib/email-queue.ts
import { Queue } from 'bullmq'
import { LoopsClient } from 'loops'

const emailQueue = new Queue('email', {
  connection: {
    host: process.env.REDIS_HOST,
    port: parseInt(process.env.REDIS_PORT!),
  },
})

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

export async function queueTransactionalEmail(
  transactionalId: string,
  email: string,
  dataVariables: Record<string, any>,
  priority: 'high' | 'normal' | 'low' = 'normal'
) {
  const priorityMap = { high: 1, normal: 2, low: 3 }

  await emailQueue.add(
    'send-transactional',
    { transactionalId, email, dataVariables },
    {
      priority: priorityMap[priority],
      attempts: 3,
      backoff: {
        type: 'exponential',
        delay: 1000,
      },
    }
  )
}

// workers/email.worker.ts
import { Worker } from 'bullmq'

const worker = new Worker(
  'email',
  async job => {
    const { transactionalId, email, dataVariables } = job.data

    await loops.sendTransactionalEmail({
      transactionalId,
      email,
      dataVariables,
    })

    console.log(`Email sent: ${transactionalId} to ${email}`)
  },
  {
    connection: {
      host: process.env.REDIS_HOST,
      port: parseInt(process.env.REDIS_PORT!),
    },
    limiter: {
      max: 100,
      duration: 1000,
    },
  }
)

worker.on('failed', (job, err) => {
  console.error(`Job ${job?.id} failed:`, err)
})

Testing

Unit tests

TS__tests__/loops.test.ts
TypeScript
// __tests__/loops.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { LoopsClient } from 'loops'

vi.mock('loops', () => ({
  LoopsClient: vi.fn().mockImplementation(() => ({
    createContact: vi.fn().mockResolvedValue({ success: true }),
    sendEvent: vi.fn().mockResolvedValue({ success: true }),
    sendTransactionalEmail: vi.fn().mockResolvedValue({ success: true }),
  })),
}))

describe('Loops Integration', () => {
  let loops: LoopsClient

  beforeEach(() => {
    loops = new LoopsClient('test-api-key')
  })

  it('should create contact with correct data', async () => {
    await loops.createContact({
      email: 'test@example.com',
      firstName: 'Test',
      source: 'test',
    })

    expect(loops.createContact).toHaveBeenCalledWith({
      email: 'test@example.com',
      firstName: 'Test',
      source: 'test',
    })
  })

  it('should send event with properties', async () => {
    await loops.sendEvent({
      email: 'test@example.com',
      eventName: 'test_event',
      eventProperties: { key: 'value' },
    })

    expect(loops.sendEvent).toHaveBeenCalledWith({
      email: 'test@example.com',
      eventName: 'test_event',
      eventProperties: { key: 'value' },
    })
  })

  it('should send transactional email', async () => {
    await loops.sendTransactionalEmail({
      transactionalId: 'welcome',
      email: 'test@example.com',
      dataVariables: { name: 'Test' },
    })

    expect(loops.sendTransactionalEmail).toHaveBeenCalledWith({
      transactionalId: 'welcome',
      email: 'test@example.com',
      dataVariables: { name: 'Test' },
    })
  })
})

Integration tests with MSW

TS__tests__/loops-integration.test.ts
TypeScript
// __tests__/loops-integration.test.ts
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
import { LoopsClient } from 'loops'

const server = setupServer(
  http.post('https://app.loops.so/api/v1/contacts/create', () => {
    return HttpResponse.json({ success: true, id: 'contact_123' })
  }),

  http.post('https://app.loops.so/api/v1/events/send', () => {
    return HttpResponse.json({ success: true })
  }),

  http.post('https://app.loops.so/api/v1/transactional', () => {
    return HttpResponse.json({ success: true })
  })
)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

describe('Loops API Integration', () => {
  const loops = new LoopsClient('test-api-key')

  it('handles API errors gracefully', async () => {
    server.use(
      http.post('https://app.loops.so/api/v1/contacts/create', () => {
        return new HttpResponse(null, { status: 500 })
      })
    )

    await expect(
      loops.createContact({ email: 'test@example.com' })
    ).rejects.toThrow()
  })
})

Pricing

Loops plans

PlanPriceContactsFeatures
Free$0/mo1,000All features, unlimited emails
Starter$49/mo5,000All features, priority support
Growth$99/mo10,000All features, dedicated support
Pro$249/mo50,000Custom SLA, API priority
EnterpriseCustomUnlimitedDedicated infrastructure

Key pricing information

  • You pay for contacts, not emails - Unlimited sending on every plan
  • No hidden fees - All features available right away
  • Free tier with no time limits - Not a trial, just a smaller contact limit
  • Yearly discount - 20% off when paying annually

FAQ - frequently asked questions

Is Loops good for small startups?

Yes, Loops is ideal for startups. The free tier (1,000 contacts) is enough to get started, and the simple API and UI let you integrate quickly without dedicated DevOps.

How does Loops differ from Mailchimp?

Loops is designed specifically for SaaS:

  • Event-driven automation instead of list-based
  • Modern API (REST) vs legacy
  • Simpler UI without unnecessary features
  • Transactional and marketing in one place
  • Sensible pricing for startups

Can I migrate from another tool?

Yes, Loops offers contact import from CSV. You can also use the API for programmatic migration. The Loops team assists with migration on Growth+ plans.

How does deliverability work in Loops?

Loops has its own email infrastructure with:

  • Dedicated IP addresses
  • DKIM/SPF/DMARC configuration
  • Automatic warmup
  • Reputation monitoring
  • Deliverability ~99%

Does Loops support A/B testing?

Yes, Loops offers A/B testing for:

  • Subject lines
  • Email content
  • Send times
  • Sender names

Can I use my own HTML templates?

Yes, Loops supports:

  • Custom HTML templates
  • Drag & drop editor
  • Plain text emails
  • Dynamic content with variables