Loops Is the Mailchimp Replacement I Didn't Know I Needed
I switched a SaaS client from Mailchimp to Loops for transactional and lifecycle email. The API is clean, the mental model is sane, and I haven't looked back.
Mailchimp is fine if you're sending a monthly newsletter to a list you built from a contact form. The moment you need to send a password reset, a trial-expiry warning, and an onboarding drip from the same platform — and keep your user records in sync — it becomes a pile of duct tape. Loops solves that problem, and its API is one of the cleaner ones I've integrated in the last couple of years.
What Loops Actually Is
Loops bills itself as email for SaaS, which is a narrow enough claim that I took it seriously. The mental model is: every person in your app is a contact in Loops, and you trigger things against that contact — either a transactional email (one-off, immediate) or an event that kicks off an automated loop (their word for a sequence or workflow).
That's it. There's no campaign builder with drag-and-drop audience segments and seventeen kinds of merge tags. There's no "campaign" object at all in the API. You push contacts, you send transactional emails, you fire events. The complexity lives in the Loops UI — the visual loop editor — not in your integration code.
For a client running a B2B SaaS product — subscription billing, team accounts, a trial flow — this is exactly the right abstraction. I was previously managing three things: Mailchimp for marketing sequences, a custom Laravel Notification channel for transactional, and a hand-rolled event queue that tried to keep Mailchimp tags in sync. Loops collapsed all of that.
The API in Practice
The base URL is https://app.loops.so/api/v1. Auth is a single bearer token from your Loops settings. There's no OAuth, no refresh dance. Refreshing.
Here's the service class I landed on. I wrap the API in a single class rather than scattering Http:: calls around the codebase.
<?php
namespace App\Services;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class LoopsService
{
private string $baseUrl = 'https://app.loops.so/api/v1';
private string $apiKey;
public function __construct()
{
$this->apiKey = config('services.loops.api_key');
}
// ----------------------------------------------------------------
// Contacts
// ----------------------------------------------------------------
public function upsertContact(string $email, array $properties = []): Response
{
return $this->post('/contacts/update', array_merge(
['email' => $email],
$properties
));
}
public function deleteContact(string $email): Response
{
return $this->post('/contacts/delete', ['email' => $email]);
}
// ----------------------------------------------------------------
// Events
// ----------------------------------------------------------------
public function fireEvent(string $email, string $eventName, array $properties = []): Response
{
return $this->post('/events/send', array_merge(
['email' => $email, 'eventName' => $eventName],
$properties
));
}
// ----------------------------------------------------------------
// Transactional
// ----------------------------------------------------------------
public function sendTransactional(
string $transactionalId,
string $email,
array $dataVariables = []
): Response {
return $this->post('/transactional', [
'transactionalId' => $transactionalId,
'email' => $email,
'dataVariables' => $dataVariables,
]);
}
// ----------------------------------------------------------------
// Internal
// ----------------------------------------------------------------
private function post(string $path, array $payload): Response
{
$response = Http::withToken($this->apiKey)
->acceptJson()
->post($this->baseUrl . $path, $payload);
if ($response->failed()) {
Log::error('Loops API error', [
'path' => $path,
'status' => $response->status(),
'body' => $response->body(),
'payload' => $payload,
]);
}
return $response;
}
}
Register it in config/services.php:
'loops' => [
'api_key' => env('LOOPS_API_KEY'),
],
Then a few real-world callsites.
Sync a new user on registration:
// In your RegisteredUserController or an Observer
app(LoopsService::class)->upsertContact($user->email, [
'firstName' => $user->first_name,
'lastName' => $user->last_name,
'userId' => (string) $user->id,
'userGroup' => 'trial',
'trialEndsAt' => $user->trial_ends_at?->toIso8601String(),
]);
Fire an event when a subscription activates:
app(LoopsService::class)->fireEvent($user->email, 'subscriptionActivated', [
'planName' => $subscription->plan->name,
'mrr' => $subscription->monthly_amount,
]);
That event name — subscriptionActivated — is what you wire up in the Loops UI to trigger a loop. Your developers define the event names, your marketing person builds the sequences. Clean separation.
Send a transactional email:
app(LoopsService::class)->sendTransactional(
config('services.loops.templates.password_reset'),
$user->email,
['resetUrl' => $resetUrl, 'firstName' => $user->first_name]
);
I keep template IDs in config rather than hardcoding them. Loops generates the ID when you create a transactional template in their UI — it's a short alphanumeric string.
The Gotchas That Bit Me
Contact properties are schema-on-write. There's no schema definition step. The first time you write a property, Loops creates it. That's convenient right up until you typo trialEndsAt as trialEndAt in one place and spend twenty minutes wondering why your trial-expiry loop isn't firing for half your users. Define your property names as constants somewhere central and use them everywhere.
upsertContact is actually two endpoints under the hood, sort of. Loops has /contacts/create and /contacts/update. The update endpoint creates the contact if it doesn't exist, so I use it for everything. But if you call /contacts/create on an existing email you get a conflict error. Just use update for upserts. Don't let the name fool you.
userId is a string. Their docs say string. If you pass an integer PHP will serialize it as an integer in JSON and Loops accepts it fine — until you try to look up the contact by userId through their API and the type mismatch bites you. Cast to string explicitly. I learned this one the boring way, debugging a webhook handler.
Transactional template variables must match exactly. If your template in the Loops editor references {{resetUrl}} and you send reset_url in dataVariables, the variable renders blank — no error, no warning, just an empty string in the email. Check the variable names in the Loops UI before assuming your code is wrong.
Rate limits are 10 requests per second. That's plenty for interactive web requests. It's not plenty if you're bulk-syncing an existing user base on first deployment. I hit it immediately trying to backfill 4,000 contacts. The fix is a queued job with a rate limiter:
RateLimiter::attempt(
'loops-sync',
8, // stay under the 10 rps limit
fn() => app(LoopsService::class)->upsertContact($email, $props)
);
Or just throttle your queue worker. Either way, plan for this before your first production deploy.
When I'd Reach for Loops
Loops is the right tool when you're building a product-led SaaS and you need a single place to manage every email a user receives — onboarding sequences, feature announcements, trial nudges, billing alerts, and one-off transactionals. The API is simple enough that a junior dev can integrate it in a day, and the UI is simple enough that a non-technical founder can manage the sequences without filing a ticket.
I'd also reach for it if I'm inheriting a codebase that's using Laravel's built-in mail for transactional and some third-party for marketing sequences. The consolidation alone is worth the migration cost.
When I wouldn't use it: if you need a traditional ESP with list-based sends, A/B testing on broadcast campaigns, or deep analytics on bulk mailings — Loops isn't that. It has broadcast capability but that's not where it shines. If you're running a media company sending weekly newsletters to 200,000 subscribers and your editorial team lives in the email tool, Mailchimp or Klaviyo or Beehiiv are better fits.
I also wouldn't use it as a drop-in Postmark/Mailgun replacement for pure transactional volume at scale. Loops transactional is great for product emails. For high-volume system notifications — think thousands of alert emails per hour — a dedicated transactional provider with better deliverability SLAs is the right call.
The Honest Take
I've integrated a lot of email APIs. Most of them are either too simple (just send this SMTP payload) or too complicated (here's your campaign object with nested segment conditions and a webhook labyrinth). Loops hits a sweet spot I didn't know existed: opinionated enough to keep your integration clean, flexible enough to cover the real lifecycle of a SaaS user.
The fact that I can keep marketing sequences entirely out of my application code — fire an event, let the Loops UI handle the rest — is worth a lot. That's one fewer reason for a marketer to ask me to deploy something on a Friday afternoon.
Need help shipping something like this? Get in touch.