These integration guides are not official documentation and the Strapi Support Team will not provide assistance with them.
What Is Loops?
Loops is an email platform designed for SaaS companies that unifies product, marketing, and transactional email under one roof. Instead of maintaining separate tools for marketing campaigns and transactional sends, Loops handles both through a single API and dashboard.
For developers, the relevant pieces include a REST (Representational State Transfer) API for contact management, transactional sends, and event tracking; an official JavaScript/TypeScript SDK; and an automation system where events you fire from your backend trigger email sequences without manual dashboard configuration. Events are created automatically the first time you send a new eventName — no pre-registration required.
Loops uses contact-based pricing rather than per-email volume. The free tier supports up to 1,000 subscribed contacts with up to 4,000 sends per month, which is enough to build and test a full integration before committing.
Sign up for the Logbook, Strapi's Monthly newsletter
Why Integrate Loops with Strapi
Strapi v5 handles content modeling and API delivery. Loops handles email. Connecting them creates a content-to-inbox pipeline where publishing actions in Strapi automatically drive subscriber communication with no manual campaign triggers and no copy-pasting content into email templates.
Here's what this integration gives you:
- Automated transactional emails from content events. Strapi lifecycle hooks fire when content is created, updated, or published. You wire those directly to Loops' transactional API to send welcome emails, order confirmations, or content notifications.
- Centralized contact management. Subscriber data lives in Strapi as your source of truth, and syncs to Loops for email delivery. Changes in Strapi propagate to Loops automatically through custom services.
- Event-driven email sequences without dashboard work. Fire named events from Strapi (e.g.,
articlePublished,userSignedUp) and Loops builds automation around them. New event names are created automatically on first send. - Newsletter subscription management through Strapi's API. Your frontend talks to Strapi, Strapi talks to Loops, keeping your API keys server-side where they belong (Loops has no CORS (Cross-Origin Resource Sharing) support, so browser-side calls aren't an option).
- Personalized content delivery at scale. Loops' template variable system accepts dynamic data from Strapi content, so each subscriber gets relevant articles, product updates, or onboarding content based on their profile.
- Clean separation of concerns. Strapi manages content and business logic. Loops manages email rendering and deliverability. Neither tool needs to do the other's job.
How to Integrate Loops with Strapi
This section walks through the full setup: from project creation to sending your first transactional email triggered by a Strapi content event.
Prerequisites
Before starting, make sure you have:
- Node.js v20, v22, or v24 (LTS versions only; odd-numbered releases like v23 are not supported by Strapi v5)
- npm 6+
- A Strapi v5 project (we'll create one below if you don't have one)
- A Loops account with an API key generated from your Loops dashboard
- A transactional email template created in Loops (you'll need the
transactionalId) - Familiarity with Strapi's Content Types and basic API customization
Step 1: Create a Strapi v5 Project
If you're starting fresh, scaffold a new project:
npx create-strapi@latest my-loops-app --quickstartThe --quickstart flag uses SQLite so you skip database configuration prompts. For production, you'd swap to PostgreSQL or MySQL v8+.
Once the install completes, Strapi opens the Admin Panel at http://localhost:1337/admin. Create your admin account and leave the server running.
Step 2: Install the Loops SDK
In your Strapi project root, install the official Loops package:
npm install loopsThis installs the loops package (v6.0.1 at time of writing), which includes full TypeScript support and requires Node.js 18.0.0 or higher. The package name on npm is simply loops, not @loops/node or loops-sdk.
Step 3: Configure Environment Variables
Add your Loops credentials to the .env file in your Strapi project root:
# Loops Integration
LOOPS_API_KEY=your_loops_api_key_here
LOOPS_WELCOME_TEMPLATE_ID=your_template_id
LOOPS_NEWSLETTER_LIST_ID=your_mailing_list_idNever hardcode API keys in source files. Strapi loads .env automatically, so these values are accessible via process.env throughout your application.
Step 4: Create a Shared Loops Client
Create a singleton client that every Strapi service can import. This avoids instantiating the SDK multiple times:
// ./src/loops-client.js
import { LoopsClient } from "loops";
if (!process.env.LOOPS_API_KEY) {
throw new Error("LOOPS_API_KEY environment variable is not set");
}
const loops = new LoopsClient(process.env.LOOPS_API_KEY);
export default loops;You can verify the connection works by testing the API key. Add a temporary check in your Strapi environment configuration or run it once:
import { LoopsClient } from "loops";
const loops = new LoopsClient(process.env.LOOPS_API_KEY);
const resp = await loops.testApiKey();
console.log(resp); // { success: true, teamName: "Your Team" }Step 5: Build the Subscriber Content Type
In your Strapi Admin Panel, create a new Collection Type called Subscriber with these fields:
| Field Name | Type | Notes |
|---|---|---|
email | Required, unique | |
firstName | Short text | Required |
lastName | Short text | Optional |
newsletterOptIn | Boolean | Default: true |
loopsSynced | Boolean | Default: false (tracking field) |
Save the Content Type. Strapi generates the API at api::subscriber.subscriber with full CRUD (Create, Read, Update, Delete) routes out of the box.
Step 6: Create the Loops Sync Service
This service encapsulates all Loops SDK calls. Create it in your subscriber API directory:
// ./src/api/subscriber/services/loops-sync.js
import loops from '../../../loops-client.js';
import { APIError, RateLimitExceededError } from 'loops';
export default {
async createContact(email, userData) {
try {
return await loops.createContact({
email,
properties: {
firstName: userData.firstName,
lastName: userData.lastName || '',
source: 'Strapi Signup',
},
mailingLists: {
[process.env.LOOPS_NEWSLETTER_LIST_ID]: userData.newsletterOptIn ?? true,
},
});
} catch (error) {
if (error instanceof RateLimitExceededError) {
strapi.log.warn(`Loops rate limit hit for ${email}`);
} else if (error instanceof APIError) {
strapi.log.error('Loops API error:', error.json);
}
throw error;
}
},
async syncContact(email, properties) {
return await loops.updateContact({ email, properties });
},
async deleteContact(email) {
return await loops.deleteContact({ email });
},
async sendTransactionalEmail(templateId, recipientEmail, data) {
return await loops.sendTransactionalEmail({
transactionalId: templateId,
to: recipientEmail,
data,
addToAudience: true,
});
},
};The updateContact method performs an upsert. It creates the contact if one doesn't exist with that email. This is useful for idempotent sync operations where you don't want to check existence first.
Step 7: Wire Up Lifecycle Hooks
Lifecycle hooks let you trigger Loops calls automatically when Strapi records change. Define them in the subscriber Content Type's lifecycles file:
// ./src/api/subscriber/content-types/subscriber/lifecycles.js
import loops from '../../../../loops-client.js';
import { RateLimitExceededError } from 'loops';
export default {
async afterCreate(event) {
const { result } = event;
try {
// Sync new subscriber to Loops
await loops.updateContact({
email: result.email,
properties: {
firstName: result.firstName,
source: 'Strapi Signup',
},
mailingLists: {
[process.env.LOOPS_NEWSLETTER_LIST_ID]: result.newsletterOptIn,
},
});
// Send welcome email
await loops.sendTransactionalEmail({
transactionalId: process.env.LOOPS_WELCOME_TEMPLATE_ID,
to: result.email,
data: { firstName: result.firstName },
});
} catch (error) {
if (error instanceof RateLimitExceededError) {
strapi.log.warn(`Loops rate limit hit for ${result.email}`);
} else {
strapi.log.error('Loops sync error:', error.message);
}
// Don't rethrow — let the Strapi record creation succeed even if Loops fails
}
},
async afterUpdate(event) {
const { result } = event;
await loops.updateContact({
email: result.email,
properties: {
firstName: result.firstName,
lastName: result.lastName || '',
},
mailingLists: {
[process.env.LOOPS_NEWSLETTER_LIST_ID]: result.newsletterOptIn,
},
}).catch((err) => strapi.log.error('Loops update error:', err.message));
},
async afterDelete(event) {
const { result } = event;
// GDPR (General Data Protection Regulation) compliance: remove contact from Loops when deleted from Strapi
await loops.deleteContact({ email: result.email }).catch(
(err) => strapi.log.error('Loops delete error:', err.message)
);
},
};Notice the error handling pattern: catch and log Loops errors without rethrowing. This ensures Strapi's database operation succeeds even if the Loops API is temporarily unavailable. You can add a retry queue for production reliability.
Step 8: Add a Custom Subscribe Endpoint
Create a public endpoint that your frontend can call for newsletter signups. This keeps your Loops API key server-side:
// ./src/api/subscriber/controllers/subscriber.js
import { factories } from '@strapi/strapi';
const { createCoreController } = factories;
export default createCoreController('api::subscriber.subscriber', ({ strapi }) => ({
async subscribe(ctx) {
const { email, firstName, lastName, newsletterOptIn } = ctx.request.body;
if (!email || !firstName) {
return ctx.badRequest('Email and firstName are required');
}
// Create the Strapi record — lifecycle hook handles Loops sync
const entry = await strapi.documents('api::subscriber.subscriber').create({
data: {
email,
firstName,
lastName: lastName || '',
newsletterOptIn: newsletterOptIn ?? true,
loopsSynced: true,
},
status: 'published',
});
ctx.body = { success: true, subscriberId: entry.documentId };
},
}));Register the custom route:
// ./src/api/subscriber/routes/custom-subscriber.js
export default {
routes: [
{
method: 'POST',
path: '/subscribers/subscribe',
handler: 'subscriber.subscribe',
config: {
auth: false, // Public endpoint
policies: [],
middlewares: [],
},
},
],
};After restarting Strapi, you can test the endpoint:
curl -X POST http://localhost:1337/api/subscribers/subscribe \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com", "firstName": "Jane", "newsletterOptIn": true}'This creates a Strapi subscriber record, syncs the contact to Loops, and sends the welcome email, all from a single API call.
Step 9: Trigger Event-Based Emails
Events are how you fire Loops automation sequences from Strapi. Add an event-triggering method to your service:
// Add to ./src/api/subscriber/services/loops-sync.js
async triggerEvent(email, eventName, eventProperties = {}) {
return await fetch("https://app.loops.so/api/v1/events/send", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.LOOPS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email, eventName, eventProperties }),
});
},Then call it from anywhere in your Strapi code:
// In a controller or lifecycle hook
const loopsSync = strapi.service('api::subscriber.subscriber');
await loopsSync.triggerEvent(user.email, 'planUpgraded', {
newPlan: 'Pro',
price: 29,
});The first time Loops sees a new eventName, it creates the event automatically in your dashboard. From there, you build automation workflows in the Loops UI, with no additional API calls needed.
Project Example: Content Newsletter Platform with Automated Digests
Let's build something concrete: a newsletter system where editorial teams publish articles in Strapi and subscribers receive automated notifications with personalized content. This combines everything from the integration steps above into a working project.
Define the Content Types
You need two Content Types in Strapi. First, add an Article Collection Type through the Admin Panel:
| Field Name | Type | Notes |
|---|---|---|
title | Short text | Required |
slug | UID (from title) | Required, unique |
content | Rich text | The article body |
excerpt | Long text | For email previews |
authorEmail | Notified on publish | |
category | Enumeration | engineering, product, company |
The Subscriber Collection Type from the integration steps above works as-is.
Create the Article Publish Hook
When an article is published, two things happen: the author gets a confirmation email, and a Loops event fires to kick off the subscriber notification sequence.
// ./src/api/article/content-types/article/lifecycles.js
import loops from '../../../../loops-client.js';
export default {
async afterCreate(event) {
const { result } = event;
// Only trigger for published articles
if (!result.publishedAt) return;
try {
// Notify the author
await loops.sendTransactionalEmail({
transactionalId: process.env.LOOPS_ARTICLE_PUBLISHED_TEMPLATE_ID,
email: result.authorEmail,
dataVariables: {
articleTitle: result.title,
articleUrl: `${process.env.FRONTEND_URL}/articles/${result.slug}`,
},
});
// Fire event for subscriber automation
await fetch("https://app.loops.so/api/v1/events/send", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.LOOPS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
email: result.authorEmail,
eventName: "articlePublished",
eventProperties: {
title: result.title,
slug: result.slug,
category: result.category,
excerpt: result.excerpt,
},
}),
});
strapi.log.info(`Loops notified for article: ${result.title}`);
} catch (error) {
strapi.log.error('Article publish notification failed:', error.message);
}
},
};Build the Digest Service
For weekly digests, create a service that fetches recent articles and sends a curated email to each subscriber:
// ./src/api/article/services/digest.js
import loops from '../../../loops-client.js';
export default {
async sendWeeklyDigest() {
const oneWeekAgo = new Date();
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
// Fetch articles published in the last 7 days
const articles = await strapi.entityService.findMany('api::article.article', {
publicationState: 'live',
filters: {
publishedAt: { $gte: oneWeekAgo.toISOString() },
},
sort: { publishedAt: 'desc' },
limit: 5,
});
if (articles.length === 0) {
strapi.log.info('No articles this week — skipping digest');
return;
}
// Fetch opted-in subscribers
const subscribers = await strapi.documents('api::subscriber.subscriber').findMany({
status: 'published',
filters: { newsletterOptIn: true },
});
const topArticle = articles[0];
// Send digest to each subscriber (throttled for rate limits)
for (const subscriber of subscribers) {
await loops.sendTransactionalEmail({
transactionalId: process.env.LOOPS_DIGEST_TEMPLATE_ID,
to: subscriber.email,
data: {
firstName: subscriber.firstName,
articleCount: String(articles.length),
topArticleTitle: topArticle.title,
topArticleUrl: `${process.env.FRONTEND_URL}/articles/${topArticle.slug}`,
digestUrl: `${process.env.FRONTEND_URL}/newsletter/latest`,
},
});
// Respect Loops' 10 req/sec rate limit
await new Promise((resolve) => setTimeout(resolve, 150));
}
strapi.log.info(`Digest sent to ${subscribers.length} subscribers`);
},
};Note the 150ms delay between sends. Loops enforces 10 requests per second per team. Without throttling, you'd hit HTTP 429 errors on lists larger than ten subscribers.
Register a Cron Job
Schedule the digest to run weekly using Strapi's cron configuration:
// ./config/cron-tasks.js
export default {
'0 9 * * 1': async ({ strapi }) => {
// Every Monday at 9:00 AM
strapi.log.info('Running weekly digest...');
await strapi.service('api::article.digest').sendWeeklyDigest();
},
};Enable cron in your server config:
// ./config/server.js
export default ({ env }) => ({
host: env('HOST', 'localhost'),
port: env.int('PORT', 1337),
cron: {
enabled: true,
},
});Handle Subscription Management
Add an unsubscribe endpoint so your frontend can manage subscriber preferences through Strapi rather than hitting Loops directly. This uses the Document Service API to locate and update the subscriber record:
// Add to ./src/api/subscriber/controllers/subscriber.js
async unsubscribe(ctx) {
const { email } = ctx.request.body;
if (!email) {
return ctx.badRequest('Email is required');
}
// Update Strapi record
const existing = await strapi.documents('api::subscriber.subscriber').findFirst({
filters: { email },
});
if (!existing) {
return ctx.notFound('Subscriber not found');
}
await strapi.documents('api::subscriber.subscriber').update({
documentId: existing.documentId,
data: { newsletterOptIn: false },
});
// Lifecycle hook handles Loops sync — but you can also call directly:
await loops.updateContact({
email,
mailingLists: {
[process.env.LOOPS_NEWSLETTER_LIST_ID]: false,
},
});
ctx.body = { success: true, message: 'Unsubscribed successfully' };
},Register the route:
// Add to ./src/api/subscriber/routes/custom-subscriber.js
{
method: 'POST',
path: '/subscribers/unsubscribe',
handler: 'subscriber.unsubscribe',
config: {
auth: false,
policies: [],
middlewares: [],
},
},The architecture is straightforward: your frontend calls Strapi, Strapi manages the data and calls Loops. Subscribers never interact with the Loops API directly, which is by design, since Loops doesn't support CORS.
Add the Environment Variables
Your complete .env for this project:
# Strapi
HOST=0.0.0.0
PORT=1337
APP_KEYS=appkeyvalue1,appkeyvalue2,appkeyvalue3,appkeyvalue4
API_TOKEN_SALT=anapitokensalt
ADMIN_JWT_SECRET=youradminjwtsecret
# Loops
LOOPS_API_KEY=your_loops_api_key
LOOPS_WELCOME_TEMPLATE_ID=clx_welcome_template
LOOPS_NEWSLETTER_LIST_ID=cm06f5v0e45nf0ml5754o9cix
LOOPS_ARTICLE_PUBLISHED_TEMPLATE_ID=clx_article_published
LOOPS_DIGEST_TEMPLATE_ID=clx_weekly_digest
# Frontend
FRONTEND_URL=https://yoursite.comOne important note on Loops templates: every non-optional variable defined in your template must be present in the data object when you call sendTransactionalEmail. Missing a required variable causes the send to fail. You get an error response instead of a delivered email, with no fallback to blank placeholders.
Strapi Open Office Hours
If you have any questions about Strapi 5 or just would like to stop by and say hi, you can join us at Strapi's Discord Open Office Hours, Monday through Friday, from 12:30 pm to 1:30 pm CST: Strapi Discord Open Office Hours.
For more details, visit the Strapi documentation and Loops documentation.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.