Node SDK — LoopNow developer docs
Official Node SDK for LoopNow. npm install loopnow, TypeScript types included, full reference for Client, Contacts, Campaigns, Automations, Transactional, Analytics. Node 18+, Bun, Deno, Cloudflare Workers.
On this page
Install
npm install loopnow
# or
pnpm add loopnow
# or
yarn add loopnow
Requires Node 18 or later. The SDK is written in TypeScript and ships its own type definitions; no @types/loopnow package needed. Zero runtime dependencies beyond undici (the native Node fetch) and zod for runtime validation.
The SDK works on Bun and Deno without changes. For Cloudflare Workers, see the special note below.
Quickstart (5 lines)
import { LoopNow } from "loopnow";
const client = new LoopNow({ apiKey: "ln_live_xxxxxxxxxxxxxxxxxxxx" });
await client.campaigns.send({ campaignId: "cmp_xyz789" });
console.log("Sent!");
TypeScript types
Every method, parameter, and return value is typed. The IDE will autocomplete contact fields, campaign parameters, and analytics responses:
import { LoopNow, type Contact, type Campaign } from "loopnow";
const client = new LoopNow({ apiKey: process.env.LOOPNOW_KEY! });
// contact.email is typed as `string`
// contact.tags is typed as `string[]`
// contact.customFields is typed as `Record<string, unknown>`
const contact: Contact = await client.contacts.create({
email: "riya@example.com",
firstName: "Riya",
consent: {
source: "homepage_signup",
ip: "203.0.113.42",
text: "I agree to receive marketing emails."
}
});
For custom contact fields, you can extend the types:
declare module "loopnow" {
interface CustomContactFields {
plan: "starter" | "scale" | "enterprise";
signup_source: string;
}
}
After this declaration, contact.customFields.plan is typed as the union, not unknown.
Client
The LoopNow class is the entry point. Construct once, reuse everywhere.
import { LoopNow } from "loopnow";
const client = new LoopNow({
apiKey: "ln_live_xxxxxxxxxxxxxxxxxxxx", // required
timeout: 30_000, // default 30s
maxRetries: 3, // default 3, exponential backoff
workspaceId: "ws_xyz789", // optional, inferred from key
apiBase: "https://api.loopnow.in/v1" // default, override for testing
});
For Cloudflare Workers, use the special LoopNow.fetch export that lets you pass a custom fetch implementation (the Workers fetch handler):
import { LoopNow } from "loopnow/cloudflare";
export default {
async fetch(request, env) {
const client = new LoopNow({
apiKey: env.LOOPNOW_KEY,
fetch: fetch.bind(globalThis) // use Workers fetch
});
// ... use client
}
};
Contacts
Create
const contact = await client.contacts.create({
email: "riya@example.com",
firstName: "Riya",
lastName: "Shah",
listIds: ["aud_newsletter"],
tags: ["vip"],
consent: {
source: "homepage_signup",
ip: "203.0.113.42",
text: "I agree to receive marketing emails."
}
});
console.log(contact.id); // "ct_def456"
consent is required. The SDK refuses to create a contact without it. The consent receipt is written to an append-only, signed audit log with the exact text, source URL, IP, and user agent.
List with pagination
// Auto-paginated iterator
for await (const contact of client.contacts.list({ tags: ["vip"] })) {
console.log(contact.email, contact.firstName);
}
// Or manual page-by-page
let page = await client.contacts.list({ limit: 100 });
while (page.data.length > 0) {
for (const contact of page.data) {
console.log(contact.email);
}
page = page.nextCursor ? await client.contacts.list({ cursor: page.nextCursor }) : null;
}
Update
await client.contacts.update("ct_def456", {
tags: ["vip", "early-access"],
customFields: { plan: "scale" }
});
Delete (right-to-erasure)
await client.contacts.delete("ct_def456", { hardDelete: false });
Soft delete by default. The contact is suppressed from future sends but the record is retained for 30 days to allow recovery. Hard delete (immediate, irreversible, full data purge) is available via hardDelete: true.
Campaigns
Create and send
const campaign = await client.campaigns.create({
name: "January newsletter",
from: { email: "hello@yourdomain.in", name: "Your Brand" },
subject: "What shipped in January",
previewText: "3 product updates, 2 case studies, 1 customer story.",
audienceId: "aud_newsletter",
templateId: "tpl_newsletter_v3"
});
await client.campaigns.send({ campaignId: campaign.id });
Schedule for later
await client.campaigns.send({
campaignId: campaign.id,
sendAt: new Date("2026-01-20T09:00:00Z")
});
Automations
List
for await (const automation of client.automations.list()) {
console.log(automation.id, automation.name, automation.status);
}
Trigger for a contact
await client.automations.trigger({
automationId: "aut_welcome_series",
contactEmail: "newuser@example.com",
context: {
plan: "scale",
trialDays: 14
}
});
The context object is exposed to the automation's email templates as merge variables. In a template, {{context.plan}} resolves to "scale" for that specific recipient.
Transactional
await client.transactional.send({
to: "user@example.com",
from: { email: "no-reply@yourdomain.in", name: "Your Brand" },
subject: "Your order #1234 is confirmed",
html: "<h1>Thanks for your order</h1>",
metadata: { orderId: "1234" },
idempotencyKey: "order-1234-confirmation"
});
The idempotencyKey makes the call safe to retry on network failures without risking duplicate emails. LoopNow stores the key for 24 hours and returns the same response on subsequent calls with the same key.
Analytics
const stats = await client.analytics.campaign("cmp_xyz789");
console.log(`Sent: ${stats.sent}`);
console.log(`Open rate: ${(stats.openRate * 100).toFixed(1)}%`);
console.log(`Complaint rate: ${(stats.complaintRate * 100).toFixed(2)}%`);
Returns a typed CampaignStats object. For real-time event-level data, subscribe to the webhooks instead.
Error handling
The SDK throws typed errors you can discriminate on with instanceof:
import { LoopNow, LoopNowError } from "loopnow";
const client = new LoopNow({ apiKey: process.env.LOOPNOW_KEY! });
try {
await client.campaigns.send({ campaignId: "cmp_does_not_exist" });
} catch (err) {
if (err instanceof LoopNowError.NotFoundError) {
console.log(`Not found: ${err.requestId}`);
} else if (err instanceof LoopNowError.RateLimitError) {
console.log(`Rate limited, retry after ${err.retryAfter}s`);
} else if (err instanceof LoopNowError) {
console.log(`LoopNow error ${err.code}: ${err.message}`);
} else {
throw err;
}
}
For transient errors (RateLimitError, ServerError, network timeouts), the SDK retries automatically with exponential backoff up to maxRetries times.
Webhooks
The SDK includes a webhook handler utility for Express, Koa, Hono, and the standard http server:
import express from "express";
import { WebhookHandler } from "loopnow/webhooks";
const app = express();
const wh = new WebhookHandler({ webhookSecret: "whsec_xxx" });
app.post("/loopnow/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
const event = await wh.verifyAndParse(req);
if (event.type === "contact.created") {
await syncToCRM(event.data);
}
res.json({ received: true });
});
The handler does the signature verification, the timestamp check, and the JSON parsing. The event object is a discriminated union over the event type, with full type narrowing in TypeScript:
if (event.type === "contact.created") {
// event.data is typed as ContactCreatedData
console.log(event.data.email);
}
if (event.type === "campaign.opened") {
// event.data is typed as CampaignOpenedData
console.log(event.data.openedAt);
}
Analytics deeper dive
For real-time event-level data, the analytics namespace exposes a streaming API:
for await (const event of client.analytics.streamEvents({ campaignId: "cmp_xyz789" })) {
console.log(`${event.email} ${event.type} at ${event.timestamp}`);
}
For historical analytics with time-series aggregation:
const stats = await client.analytics.campaign("cmp_xyz789", {
start: new Date("2026-01-01"),
end: new Date("2026-01-31"),
granularity: "day"
});
for (const day of stats.timeseries) {
console.log(`${day.date}: sent=${day.sent} opens=${day.opens} clicks=${day.clicks}`);
}
Type safety with Zod
Every return value from the SDK is parsed through Zod. The runtime validation means you can trust that the data you get is well-formed. You can also re-use the Zod schemas to validate your own data:
import { z } from "loopnow";
const ContactSchema = z.contact();
const result = ContactSchema.safeParse(untrustedData);
if (result.success) {
// result.data is a typed Contact
}
Testing with the sandbox
Use ln_test_ prefixed keys for the sandbox:
const client = new LoopNow({
apiKey: process.env.LOOPNOW_TEST_KEY!,
apiBase: "https://api-sandbox.loopnow.in/v1"
});
The sandbox is a fully separate workspace with its own data, rate limits, and webhook endpoints. Safe to use in CI/CD.
Open source
Source: github.com/loopnow/loopnow-node. MIT licence. Issues and PRs welcome.