Claude Code with Loops: Email That Renders Across Clients
Why Loops without CLAUDE.md sends emails that look right in Gmail and break in Outlook
Loops is the modern email-for-SaaS platform of 2026. The pitch is simple: one tool for transactional emails (signup confirmation, password reset, receipt), marketing emails (announcements, weekly digest), and lifecycle email automation (onboarding sequences, win-back campaigns), with deliverability infrastructure that handles SPF, DKIM, DMARC, suppression lists, and bounce processing automatically. The integration is straightforward. The API is small. The problem is that Loops puts the template content in the dashboard and the send trigger in your code, and the two are connected by a transactional ID that has no validation. When Claude Code generates send calls with the wrong transactional ID, or with data variables that do not match the template's variable definitions, the send returns success and the customer receives nothing, or worse, receives a half-rendered email with literal {{variable}} placeholders.
The most common Claude defaults that break Loops integrations: hardcoding transactional template IDs as inline strings instead of in a constants file, sending data variables with keys that do not match the dashboard template (so firstName shows as literal {{firstName}}), confusing the transactional send endpoint with the event endpoint (transactional sends one email immediately, events trigger lifecycle automation), omitting the contact-creation step before sending to a never-seen email address, and using the legacy v1 API endpoints when the current SDK is v2.
This guide covers the CLAUDE.md configuration that locks Claude Code into Loops's correct model: a single source of truth for transactional template IDs, data variable validation against the template definitions, the contact-then-send pattern for new email addresses, the event endpoint for lifecycle triggers, and the SDK initialisation pattern that works in both server and edge contexts. If you are sending transactional email outside the lifecycle automation system, Claude Code with Resend covers a more developer-direct alternative. For analytics on send-through-to-conversion, Claude Code with Langfuse covers the trace pattern that correlates email opens to downstream LLM-driven engagement.
The Loops CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Loops-integrated SaaS application it needs to declare: the SDK version, the environment variable name for the API key, the transactional ID constants location, the data variable contract, the contact creation policy, and the hard rules that block the mistakes Claude makes most often.
# Loops rules
## Stack
- loops ^4.x (Node SDK)
- LOOPS_API_KEY in .env (server-only, never client bundle)
- Loops dashboard at app.loops.so
- Transactional templates defined in dashboard, referenced by ID in code
## Project structure
- src/lib/loops.ts , Loops client singleton
- src/lib/email-templates.ts , Transactional template ID constants
- src/lib/email-variables.ts , Data variable contract per template
- src/lib/send-email.ts , Wrapper functions per template
## Transactional send pattern (MANDATORY)
Every loops.sendTransactionalEmail() call MUST include:
- transactionalId: TEMPLATES.X (from email-templates.ts, NEVER inline string)
- email: recipient address
- dataVariables: object matching the template's variable contract
NEVER inline a transactional ID like 'cm5x2qabc...' directly in send code.
ALWAYS reference TEMPLATES.WELCOME_EMAIL or similar named constant.
## Data variables (MANDATORY contract)
- Define variables per template in email-variables.ts
- Send function signature accepts typed args, NOT a generic Record
- Mismatched variable keys render as literal {{varName}} in the email
- Variables not used in the template are silently discarded (no error)
## Hard rules
- NEVER hardcode transactionalId as an inline string
- NEVER send dataVariables as a generic Record<string, unknown>
- NEVER call sendTransactionalEmail() to an email that has never been a Loops contact
- NEVER expose LOOPS_API_KEY to the client bundle
- NEVER use the v1 endpoints (app.loops.so/api/v1/*), use SDK v2+
- ALWAYS check the { success, message } response before proceeding
Three rules here prevent the majority of production failures Claude generates without them.
The transactional ID constants rule is the most impactful because Loops's transactional IDs are long random strings (e.g. cm5x2qabc123def456). When Claude writes them inline in send code, they cannot be searched, renamed, or audited. Worse, a single character typo turns a valid send into a 404 that returns { success: false } with a message Claude often does not check. A constants file at src/lib/email-templates.ts makes the IDs grep-able, refactorable, and reviewable.
The data variable contract rule prevents the literal-placeholder failure mode. Loops's template variables are defined in the dashboard. If the dashboard template uses {{firstName}} and your send call passes { first_name: 'Hugo' }, the email goes out with the literal text {{firstName}} in place of the name. There is no error from the API. The send returns success. The customer sees broken email. Defining a typed contract per template in code (so WELCOME_EMAIL_DATA = { firstName: string, loginUrl: string }) and using a wrapper function that enforces the typed shape prevents the mismatch.
The contact-before-send rule prevents the "first send to new user silently failed" pattern. Loops uses the contact list as the canonical record of who can receive email. Transactional sends to addresses that have never been added as contacts succeed at the API level but may not actually deliver depending on workspace settings. The reliable pattern is: create or update the contact, then send. The contact creation is idempotent (Loops accepts repeated createContact calls with the same email) so the additional step is safe to run on every send.
Install and key setup
Install the Loops SDK:
npm i loops
Add the API key to your environment file. The key is server-only, never expose it to the client bundle:
# .env.local
LOOPS_API_KEY=your_loops_api_key_here
Create the singleton client:
// src/lib/loops.ts
import { LoopsClient } from 'loops';
if (!process.env.LOOPS_API_KEY) {
throw new Error('LOOPS_API_KEY is not defined');
}
export const loops = new LoopsClient(process.env.LOOPS_API_KEY);
The startup check surfaces a missing key immediately when the server boots rather than at the moment of the first send.
Define the transactional template ID constants:
// src/lib/email-templates.ts
export const TEMPLATES = {
WELCOME_EMAIL: 'cm5x2qabc123def456',
PASSWORD_RESET: 'cm5y3qdef789ghi012',
PAYMENT_RECEIPT: 'cm5z4qghi345jkl678',
TRIAL_EXPIRING: 'cm6a5qjkl901mno234',
TRIAL_EXPIRED: 'cm6b6qmno567pqr890',
} as const;
export type TemplateId = (typeof TEMPLATES)[keyof typeof TEMPLATES];
The IDs come from the Loops dashboard. Each transactional template has an ID shown in the dashboard URL or in the template settings panel. Copy them into the constants file once, then reference by name everywhere.
Define the data variable contract per template:
// src/lib/email-variables.ts
export interface WelcomeEmailData {
firstName: string;
loginUrl: string;
trialDaysRemaining: number;
}
export interface PasswordResetData {
firstName: string;
resetUrl: string;
expiresIn: string;
}
export interface PaymentReceiptData {
firstName: string;
amount: string;
currency: string;
invoiceUrl: string;
receiptNumber: string;
}
export interface TrialExpiringData {
firstName: string;
daysRemaining: number;
upgradeUrl: string;
}
Add the singleton and constants pattern to CLAUDE.md:
## Client singleton and constants
- The only Loops client lives at src/lib/loops.ts
- All transactional IDs live in src/lib/email-templates.ts as TEMPLATES constants
- All variable shapes live in src/lib/email-variables.ts as TypeScript interfaces
- Claude MUST NOT instantiate new LoopsClient() inline
- Claude MUST NOT use inline transactionalId strings
- Updating templates: change ONE constant, all references update
Sending a transactional email
The wrapper function pattern enforces the typed variable contract:
// src/lib/send-email.ts
import { loops } from '@/lib/loops';
import { TEMPLATES } from '@/lib/email-templates';
import {
WelcomeEmailData,
PasswordResetData,
PaymentReceiptData,
TrialExpiringData,
} from '@/lib/email-variables';
export async function sendWelcomeEmail(email: string, data: WelcomeEmailData) {
const response = await loops.sendTransactionalEmail({
transactionalId: TEMPLATES.WELCOME_EMAIL,
email,
dataVariables: {
firstName: data.firstName,
loginUrl: data.loginUrl,
trialDaysRemaining: data.trialDaysRemaining,
},
});
if (!response.success) {
console.error('[Loops] Welcome email failed:', response.message);
throw new Error(`Welcome email send failed: ${response.message}`);
}
return response;
}
export async function sendPasswordReset(email: string, data: PasswordResetData) {
const response = await loops.sendTransactionalEmail({
transactionalId: TEMPLATES.PASSWORD_RESET,
email,
dataVariables: {
firstName: data.firstName,
resetUrl: data.resetUrl,
expiresIn: data.expiresIn,
},
});
if (!response.success) {
throw new Error(`Password reset send failed: ${response.message}`);
}
return response;
}
export async function sendPaymentReceipt(email: string, data: PaymentReceiptData) {
const response = await loops.sendTransactionalEmail({
transactionalId: TEMPLATES.PAYMENT_RECEIPT,
email,
dataVariables: {
firstName: data.firstName,
amount: data.amount,
currency: data.currency,
invoiceUrl: data.invoiceUrl,
receiptNumber: data.receiptNumber,
},
});
if (!response.success) {
throw new Error(`Payment receipt send failed: ${response.message}`);
}
return response;
}
Calling a wrapper from a route handler:
// src/app/api/auth/signup/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { sendWelcomeEmail } from '@/lib/send-email';
import { createOrUpdateContact } from '@/lib/contacts';
export async function POST(req: NextRequest) {
const { email, firstName } = await req.json();
// Create the user in your DB, get back a stable user ID
await createOrUpdateContact({
email,
firstName,
source: 'signup',
userGroup: 'trial',
});
await sendWelcomeEmail(email, {
firstName,
loginUrl: 'https://claudify.tech/login',
trialDaysRemaining: 14,
});
return NextResponse.json({ ok: true });
}
The wrapper signature sendWelcomeEmail(email, data: WelcomeEmailData) forces every caller to provide the right variables. TypeScript catches the mismatch at compile time. The dashboard template stays in sync with code because both reference the same variable names.
Add a transactional send section to CLAUDE.md:
## Transactional send
- One wrapper function per template at src/lib/send-email.ts
- Wrapper signature: (email: string, data: TypedDataInterface)
- Wrapper checks response.success, throws on failure with response.message
- ALWAYS createOrUpdateContact before first send to a new email
- ALWAYS log full response.message on failure (Loops error messages are descriptive)
- NEVER swallow the { success, message } response
Contact creation and management
Loops contacts hold the user's email plus any custom properties used in marketing filters and lifecycle automations. The contact API is idempotent: repeated calls with the same email update rather than duplicate.
// src/lib/contacts.ts
import { loops } from '@/lib/loops';
interface ContactProperties {
email: string;
firstName?: string;
lastName?: string;
source?: string;
userGroup?: 'trial' | 'paying' | 'churned';
signedUpAt?: number;
plan?: string;
}
export async function createOrUpdateContact(properties: ContactProperties) {
const response = await loops.updateContact(properties.email, properties);
if (!response.success) {
console.error('[Loops] Contact upsert failed:', response.message);
throw new Error(`Contact upsert failed: ${response.message}`);
}
return response;
}
export async function deleteContact(email: string) {
const response = await loops.deleteContact({ email });
if (!response.success) {
throw new Error(`Contact delete failed: ${response.message}`);
}
return response;
}
export async function findContact(email: string) {
const response = await loops.findContact({ email });
return response;
}
The updateContact method is the canonical contact-management call. Loops also exposes createContact (errors if the contact exists) and deleteContact. For most application flows, updateContact is correct because it handles both new and returning users.
Custom properties on contacts (e.g. userGroup, plan, signedUpAt) are the foundation of audience filters in the dashboard. A campaign that targets "trial users who signed up in the last 7 days" reads userGroup === 'trial' and signedUpAt >= now - 7 days from these properties. The properties must be set before they can be filtered on.
Add a contacts section to CLAUDE.md:
## Contacts
- ALWAYS use updateContact for upsert behaviour (idempotent)
- ALWAYS set custom properties at signup (userGroup, plan, signedUpAt)
- Property names MUST match across signup, updates, and dashboard filters
- Snake_case vs camelCase: stick to ONE convention across the project
- NEVER call deleteContact on a paying user without explicit confirmation
- For GDPR deletion: deleteContact removes from all lists AND suppression
Event-based lifecycle automation
Events trigger lifecycle automations defined in the Loops dashboard. The classic use case: a user starts a trial, an event fires, a 14-day onboarding sequence kicks off automatically. The events are decoupled from the email templates: the dashboard defines which emails go out on which events, and the application code only sends the events.
// src/lib/events.ts
import { loops } from '@/lib/loops';
export const EVENTS = {
TRIAL_STARTED: 'trial_started',
TRIAL_EXPIRING_3_DAYS: 'trial_expiring_3_days',
TRIAL_EXPIRED: 'trial_expired',
SUBSCRIPTION_STARTED: 'subscription_started',
SUBSCRIPTION_CANCELLED: 'subscription_cancelled',
FEATURE_USED: 'feature_used',
PAYMENT_FAILED: 'payment_failed',
} as const;
export async function sendEvent(
email: string,
eventName: typeof EVENTS[keyof typeof EVENTS],
eventProperties?: Record<string, unknown>,
) {
const response = await loops.sendEvent({
email,
eventName,
eventProperties,
});
if (!response.success) {
console.error(`[Loops] Event ${eventName} failed:`, response.message);
throw new Error(`Event send failed: ${response.message}`);
}
return response;
}
The naming convention is snake_case for event names because Loops's dashboard filters are case-sensitive and reading trial_started in the audience filter UI is more legible than trialStarted. Pick one convention and stick to it across the project.
Triggering from a webhook (e.g. Stripe payment failure):
// src/app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { sendEvent, EVENTS } from '@/lib/events';
export async function POST(req: NextRequest) {
const event = await verifyStripeWebhook(req);
if (event.type === 'invoice.payment_failed') {
const customerEmail = event.data.object.customer_email;
await sendEvent(customerEmail, EVENTS.PAYMENT_FAILED, {
amount: event.data.object.amount_due,
invoiceUrl: event.data.object.hosted_invoice_url,
});
}
return NextResponse.json({ ok: true });
}
The event properties carry context that the lifecycle automation can use to render the email. The dashboard email template references them as {{eventProperties.invoiceUrl}}. This is different from transactional sends, where the variables are passed as dataVariables at the top level.
Add an events section to CLAUDE.md:
## Events (lifecycle automation)
- All event names live in src/lib/events.ts as EVENTS constants
- Event names: snake_case (matches dashboard filter convention)
- sendEvent for lifecycle triggers, NOT for one-off sends
- One-off sends use sendTransactionalEmail (immediate, no automation)
- Lifecycle emails use sendEvent (dashboard automation handles routing)
- Event properties accessible in template as {{eventProperties.X}}
- ALWAYS pass eventProperties for context, even if minimal
Audience filters and mailing lists
Mailing lists in Loops are computed dynamically from audience filters. A list does not store a static array of contacts: it stores a filter (e.g. userGroup = trial AND signedUpAt > now - 7 days) and resolves to the current matching contacts every time it is used.
This has implications for code. When the application wants to send a campaign to "all trial users from this month", it does not enumerate the contacts: it sets the right properties on the contacts (userGroup = trial, signedUpAt = current timestamp) and the dashboard list filter resolves to the right set.
The flip side is that getting filters wrong has no immediate error. If your dashboard list filters on user_group = trial (snake_case) but your code sets userGroup = trial (camelCase), the list resolves to zero contacts. The campaign sends to no one. Maintaining property name consistency is critical.
A pre-flight check script that audits properties across the project:
// scripts/audit-loops-properties.js
import { loops } from '../src/lib/loops.ts';
// List a sample of contacts and inspect properties
async function audit() {
const sample = await loops.findContact({ email: 'sample-user@example.com' });
console.log('Properties on sample contact:', Object.keys(sample.properties ?? {}));
// Compare against the expected property list from your code constants
const expected = ['firstName', 'lastName', 'userGroup', 'plan', 'signedUpAt'];
const actual = Object.keys(sample.properties ?? {});
const missing = expected.filter(k => !actual.includes(k));
const unexpected = actual.filter(k => !expected.includes(k));
if (missing.length) console.error('Missing properties:', missing);
if (unexpected.length) console.warn('Unexpected properties:', unexpected);
}
audit();
Run this as part of the deployment pipeline. Drift between code and dashboard properties is hard to spot otherwise.
Add an audiences section to CLAUDE.md:
## Audiences and mailing lists
- Lists in Loops are dynamic filters, not static arrays
- Filter consistency: dashboard filter properties MUST match code property names
- Use ONE casing convention (camelCase recommended for JS/TS)
- Property naming script: scripts/audit-loops-properties.js (run in CI)
- For per-user list membership control: set a boolean property (e.g. subscribed_newsletter)
- Mailing list subscribed/unsubscribed status is managed via mailingLists field on contact
Mailing list subscription management
For users to manage which marketing lists they receive, set the mailingLists field on the contact with the list IDs and subscription state.
// src/lib/subscriptions.ts
import { loops } from '@/lib/loops';
export const MAILING_LISTS = {
WEEKLY_DIGEST: 'cm5xabc123def456',
PRODUCT_UPDATES: 'cm5yabc789def012',
COMMUNITY_DIGEST: 'cm5zabc345def678',
} as const;
export async function subscribeToWeeklyDigest(email: string) {
return loops.updateContact(email, {
email,
mailingLists: {
[MAILING_LISTS.WEEKLY_DIGEST]: true,
},
});
}
export async function unsubscribeFromWeeklyDigest(email: string) {
return loops.updateContact(email, {
email,
mailingLists: {
[MAILING_LISTS.WEEKLY_DIGEST]: false,
},
});
}
export async function unsubscribeFromAll(email: string) {
return loops.updateContact(email, {
email,
subscribed: false,
});
}
The mailing list IDs come from the Loops dashboard under Audiences. Each list has a stable ID that can be referenced from code.
Setting subscribed: false unsubscribes the contact from all marketing emails. Loops still allows transactional sends to that contact (transactional email is not subject to the unsubscribed status), but marketing campaigns and lifecycle automations are blocked.
Add a subscriptions section to CLAUDE.md:
## Subscription management
- Mailing list IDs in src/lib/subscriptions.ts as MAILING_LISTS constants
- subscribed: false blocks all marketing email (transactional still works)
- mailingLists field is a map of listId -> boolean for per-list opt-in
- Unsubscribe links in marketing emails route to a public endpoint that sets subscribed: false
- For GDPR: deleteContact removes from suppression AND lists
- Always log unsubscribe events to your own DB for audit trail
Webhook for delivery events
Loops fires webhooks for delivery events: sent, delivered, opened, clicked, bounced, complained, unsubscribed. Subscribing to these gives the application visibility into actual delivery rather than the immediate API response.
// src/app/api/webhooks/loops/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
const webhookSecret = process.env.LOOPS_WEBHOOK_SECRET;
function verifySignature(rawBody: string, signature: string) {
if (!webhookSecret) return false;
const expected = crypto
.createHmac('sha256', webhookSecret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const signature = req.headers.get('loops-signature') ?? '';
if (!verifySignature(rawBody, signature)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
const event = JSON.parse(rawBody);
switch (event.type) {
case 'email.bounced':
await handleBounce(event.data.email);
break;
case 'email.complained':
await handleComplaint(event.data.email);
break;
case 'email.unsubscribed':
await handleUnsubscribe(event.data.email);
break;
default:
break;
}
return NextResponse.json({ ok: true });
}
async function handleBounce(email: string) {
// Mark address as undeliverable in your DB
}
async function handleComplaint(email: string) {
// Suppress from all future sends, log for review
}
async function handleUnsubscribe(email: string) {
// Update local DB to reflect unsubscribe (Loops already updates contact)
}
Add a webhooks section to CLAUDE.md:
## Webhooks
- Endpoint: src/app/api/webhooks/loops/route.ts
- LOOPS_WEBHOOK_SECRET in .env, verify signature with HMAC-SHA256
- Subscribe to: email.sent, email.delivered, email.bounced, email.complained, email.unsubscribed
- email.bounced: mark address undeliverable, stop future sends
- email.complained: highest priority, immediate suppression
- email.unsubscribed: update local DB (Loops already handles its own state)
- Idempotent handlers: webhooks may be delivered more than once
Common Claude Code mistakes with Loops
Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.
1. Inline transactional ID
Claude generates: transactionalId: 'cm5x2qabc123def456' directly in the send call.
Correct pattern: transactionalId: TEMPLATES.WELCOME_EMAIL with the constant imported from email-templates.ts.
2. Untyped data variables
Claude generates: dataVariables: { firstName, loginUrl } with no type contract.
Correct pattern: sendWelcomeEmail(email, data: WelcomeEmailData) wrapper that enforces the typed shape.
3. Skipped contact creation
Claude generates: loops.sendTransactionalEmail(...) to a new email without createOrUpdateContact first.
Correct pattern: await createOrUpdateContact({ email, ... }); await loops.sendTransactionalEmail(...);
4. Confused transactional vs event
Claude generates: sendTransactionalEmail for what should be a lifecycle trigger.
Correct pattern: sendEvent(email, EVENTS.TRIAL_STARTED, properties) for lifecycle, sendTransactionalEmail for immediate one-offs.
5. Property name drift
Claude generates: code that sets user_group while the dashboard filter expects userGroup.
Correct pattern: one casing convention across code AND dashboard, audited by scripts/audit-loops-properties.js.
6. Swallowed response
Claude generates: await loops.sendTransactionalEmail({ ... }) with no check of response.success.
Correct pattern: if (!response.success) throw new Error(response.message).
Add a common mistakes section to CLAUDE.md with these six pairs.
Permission hooks for Loops scripts
A Loops-integrated project accumulates scripts: bulk imports, contact exports, audience auditors, list management. Some are read-only. Some mutate Loops state in ways that affect real users. Permission hooks gate the destructive ones.
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(node scripts/audit-loops-properties.js*)",
"Bash(node scripts/list-contacts.js*)",
"Bash(node scripts/export-contacts.js*)"
],
"deny": [
"Bash(node scripts/bulk-import.js*)",
"Bash(node scripts/bulk-delete.js*)",
"Bash(node scripts/send-campaign.js*)",
"Bash(node scripts/unsubscribe-all.js*)"
]
}
}
Auditing and listing are safe operations. Bulk imports, deletes, campaign sends, and mass unsubscribes mutate Loops state that affects real customers receiving real email. The deny list forces Claude to surface those operations as prompts rather than running them as part of an automated workflow.
Building Loops integrations that actually deliver
The Loops CLAUDE.md in this guide produces email integrations where the transactional IDs are stable constants, the data variables are typed contracts that match the dashboard templates, contacts are upserted before sends, lifecycle triggers use the event endpoint while one-offs use the transactional endpoint, mailing list subscription state is managed explicitly, and webhook handlers update local state on bounces and complaints.
The underlying principle is the same as any API integration with Claude Code. Loops without a CLAUDE.md produces code that calls the right endpoints, passes type checking, and looks correct in code review. But the dashboard and the code drift: variable names mismatch, transactional IDs become unsearchable strings, lifecycle triggers fire as immediate sends, and the audience filters quietly resolve to empty sets. The CLAUDE.md template removes each gap by making the correct pattern the only pattern Claude can generate.
For the next layer of email infrastructure, Loops works alongside Claude Code with Resend for developer-direct transactional email, and Claudify includes a Loops-specific CLAUDE.md template with the transactional ID constants pattern, typed data variable contracts, contact-before-send rules, event vs transactional separation, and all six common-mistake rules pre-configured.
Get Claudify. Ship Loops integrations that render correctly and reach the inbox.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify