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:
- Zaprojektowany dla SaaS - Nie jest kolejnym narzędziem "dla wszystkich" - jest precyzyjnie dopasowany do potrzeb software'owych firm
- Proste, nowoczesne API - Integracja w kilka minut zamiast dni
- Event-driven automation - Triggeruj emaile na podstawie zdarzeń w aplikacji
- Unified platform - Transactional, marketing i automated emails w jednym miejscu
- Developer-friendly - TypeScript SDK, webhooks, świetna dokumentacja
- Sensowny pricing - Płacisz za kontakty, nie za wysłane emaile
Loops vs alternatywy
| Cecha | Loops | Mailchimp | SendGrid | Customer.io |
|---|---|---|---|---|
| Główny fokus | SaaS | Wszyscy | Developers | Enterprise |
| Łatwość użycia | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| API | Nowoczesne REST | Legacy | Dobry | Dobry |
| 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 tier | 1,000 kontaktów | 500 kontaktów | 100 emails/day | 14 dni trial |
Instalacja i konfiguracja
Instalacja SDK
# npm
npm install loops
# yarn
yarn add loops
# pnpm
pnpm add loopsPodstawowa konfiguracja
// 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
// 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
LOOPS_API_KEY=your_api_key_here
# Opcjonalnie - custom endpoint dla self-hosted
LOOPS_API_URL=https://app.loops.so/api/v1Zarządzanie kontaktami
Tworzenie kontaktów
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
// 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
// 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.
// 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
// 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
// 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.
// 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
// 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
// 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
// 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
// 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.
// 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
// 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
// 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
// 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
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
// 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
// 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
// __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
// __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
| Plan | Cena | Kontakty | Funkcje |
|---|---|---|---|
| Free | $0/mo | 1,000 | Wszystkie funkcje, unlimited emails |
| Starter | $49/mo | 5,000 | Wszystkie funkcje, priority support |
| Growth | $99/mo | 10,000 | Wszystkie funkcje, dedicated support |
| Pro | $249/mo | 50,000 | Custom SLA, API priority |
| Enterprise | Custom | Unlimited | Dedicated 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:
- Designed for SaaS - It is not yet another tool "for everyone" - it is precisely tailored to the needs of software companies
- Simple, modern API - Integration in minutes instead of days
- Event-driven automation - Trigger emails based on events in your application
- Unified platform - Transactional, marketing, and automated emails in one place
- Developer-friendly - TypeScript SDK, webhooks, excellent documentation
- Sensible pricing - You pay for contacts, not for emails sent
Loops vs alternatives
| Feature | Loops | Mailchimp | SendGrid | Customer.io |
|---|---|---|---|---|
| Main focus | SaaS | Everyone | Developers | Enterprise |
| Ease of use | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| API | Modern REST | Legacy | Good | Good |
| 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 tier | 1,000 contacts | 500 contacts | 100 emails/day | 14-day trial |
Installation and configuration
Installing the SDK
# npm
npm install loops
# yarn
yarn add loops
# pnpm
pnpm add loopsBasic configuration
// 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
// 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
LOOPS_API_KEY=your_api_key_here
# Optional - custom endpoint for self-hosted
LOOPS_API_URL=https://app.loops.so/api/v1Contact management
Creating contacts
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
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
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.
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
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
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.
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
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
// 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
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
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.
// 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
// 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
// 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
// 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
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
// 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
// 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
// __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
// __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
| Plan | Price | Contacts | Features |
|---|---|---|---|
| Free | $0/mo | 1,000 | All features, unlimited emails |
| Starter | $49/mo | 5,000 | All features, priority support |
| Growth | $99/mo | 10,000 | All features, dedicated support |
| Pro | $249/mo | 50,000 | Custom SLA, API priority |
| Enterprise | Custom | Unlimited | Dedicated 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