@mailmodo/sdk
A lightweight, zero-dependency Node.js SDK for firing lifecycle events and updating contact properties on Mailmodo. Use it to trigger automated transactional emails from anywhere in your application — auth flows, webhooks, background jobs, or serverless functions.
Table of Contents
- Installation
- Quick Start
- Two API Patterns
- Configuration
- API Reference
- Event Names
- Usage Examples
- TypeScript Support
- Error Handling
- Retry & Timeout Behaviour
- License
Installation
# npm
npm install @mailmodo/sdk
# yarn
yarn add @mailmodo/sdk
# pnpm
pnpm add @mailmodo/sdkRequirements: Node.js >=18.0.0
Quick Start
Set your API key as an environment variable and call track — that's it.
export MAILMODO_API_KEY=mm_live_xxxxxxxxxxxxxxxxxxxximport { track } from '@mailmodo/sdk';
await track('user.signup', {
email: 'alice@example.com',
plan: 'pro',
});Two API Patterns
The SDK ships two patterns. Use whichever fits your architecture.
Global functions (recommended for most apps)
Reads MAILMODO_API_KEY from the environment automatically and lazily creates a singleton client on the first call.
import { track, identify } from '@mailmodo/sdk';
await track('user.upgraded', { email: 'alice@example.com', plan: 'enterprise' });
await identify('alice@example.com', { company: 'Acme Corp', seats: 50 });Class-based client (recommended for serverless & multi-tenant)
Instantiate a client explicitly with a config object. Useful when the API key comes from a per-request context (e.g., multi-tenant SaaS, Cloudflare Workers) or when you need different timeout/retry settings per environment.
import { createClient } from '@mailmodo/sdk';
const mailmodo = createClient({
apiKey: process.env.MAILMODO_API_KEY,
timeout: 5_000,
maxRetries: 2,
});
await mailmodo.track('user.signup', { email: 'alice@example.com' });
await mailmodo.identify('alice@example.com', { plan: 'pro' });Configuration
Both createClient() and new MailmodoClient() accept an optional config object.
| Option | Type | Default | Description |
|---|---|---|---|
apiKey |
string |
process.env.MAILMODO_API_KEY |
Your Mailmodo API key. Format: mm_live_xxxxxxxxxxxxxxxxxxxx |
baseUrl |
string |
https://api.mailmodo.dev |
API base URL. Override to http://localhost:3000 for local development. |
timeout |
number |
10000 |
Request timeout in milliseconds. |
maxRetries |
number |
3 |
Max retry attempts on retriable errors. Set to 0 to disable retries. |
If apiKey is not provided and MAILMODO_API_KEY is not set in the environment, the constructor throws a MailmodoError immediately.
API Reference
track()
Fires a lifecycle event for a contact. Triggers any automated emails you have configured for that event in Mailmodo.
Global function signature:
function track(event: EventName, properties: TrackProperties): Promise<TrackResponse>Class method signature:
mailmodoClient.track(event: EventName, properties: TrackProperties): Promise<TrackResponse>Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
event |
EventName |
Yes | The lifecycle event name. Autocompletes the 13 known events but accepts any custom string. |
properties |
TrackProperties |
Yes | An object containing at minimum an email field plus any additional key-value properties to attach to the event. |
properties.email |
string |
Yes | The contact's email address. Must be a non-empty string. |
properties[key] |
string | number | boolean | null |
No | Any additional custom properties to send with the event. |
Returns: Promise<TrackResponse>
| Field | Type | Description |
|---|---|---|
received |
boolean |
Whether the event was accepted by the API. |
emailsSent |
number (optional) |
Number of emails triggered by this event. |
skipped |
boolean (optional) |
true if the event was received but no email was sent. |
reason |
string (optional) |
Human-readable explanation when skipped is true. |
Example
const result = await track('user.payment_failed', {
email: 'alice@example.com',
invoiceId: 'inv_abc123',
amount: 99,
currency: 'USD',
});
if (result.received) {
console.log(`Triggered ${result.emailsSent ?? 0} email(s)`);
}identify()
Updates or creates properties on a contact. Use this to keep your Mailmodo contact records in sync with your application state — plan tier, usage metrics, feature flags, etc.
Global function signature:
function identify(email: string, properties: IdentifyProperties): Promise<IdentifyResponse>Class method signature:
mailmodoClient.identify(email: string, properties: IdentifyProperties): Promise<IdentifyResponse>Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
email |
string |
Yes | The contact's email address. Must be a non-empty string. |
properties |
IdentifyProperties |
Yes | Key-value pairs of contact properties to set. Values must be string, number, boolean, or null. |
Returns: Promise<IdentifyResponse>
| Field | Type | Description |
|---|---|---|
email |
string |
The contact's email address. |
properties |
Record<string, string | number | boolean | null> |
The full set of properties stored on the contact after the update. |
status |
'active' | 'suppressed' |
The contact's current suppression status. |
Example
const contact = await identify('alice@example.com', {
plan: 'enterprise',
seats: 50,
trialEnded: true,
accountManager: 'bob@acme.com',
});
console.log(contact.status); // 'active' | 'suppressed'createClient()
Factory function that returns a new MailmodoClient instance. Prefer this over new MailmodoClient() for cleaner call sites.
function createClient(config?: MailmodoClientConfig): MailmodoClientimport { createClient } from '@mailmodo/sdk';
const mailmodo = createClient({ apiKey: process.env.MAILMODO_API_KEY });MailmodoClient class
The underlying class behind createClient(). Exposes the same track() and identify() methods.
import { MailmodoClient } from '@mailmodo/sdk';
const mailmodo = new MailmodoClient({
apiKey: 'mm_live_xxxxxxxxxxxxxxxxxxxx',
timeout: 8_000,
maxRetries: 1,
});Event Names
The SDK ships 13 pre-defined event names with full autocompletion support. You can also pass any arbitrary string for custom events.
| Event Name | Typical Trigger |
|---|---|
user.signup |
New account created |
user.trial_expiry |
Trial period is ending |
user.inactive_14d |
No activity in 14 days |
user.upgraded |
Upgraded to a paid plan |
user.churned |
Subscription cancelled |
user.feature_not_used |
Key feature never activated |
user.nps_eligible |
User eligible for NPS survey |
user.churn_risk |
Identified as churn risk |
user.upsell_eligible |
Candidate for plan upgrade |
user.milestone_reached |
Hit a usage milestone |
user.password_reset |
Password reset requested |
user.payment_failed |
Charge failed |
user.renewal_upcoming |
Subscription renewing soon |
Custom event names are fully supported:
await track('user.exported_report', { email: 'alice@example.com' });Usage Examples
Example 1 — Auth flow (user signup + enrichment)
Fire a user.signup event and immediately enrich the contact with plan and source data.
import { track, identify } from '@mailmodo/sdk';
async function onUserSignup(user: { email: string; plan: string; source: string }) {
// Fire signup event to trigger the welcome email sequence
await track('user.signup', {
email: user.email,
plan: user.plan,
signupSource: user.source,
});
// Persist properties to the contact record for future segmentation
await identify(user.email, {
plan: user.plan,
signupSource: user.source,
signedUpAt: new Date().toISOString(),
});
}Example 2 — Stripe webhook handler (payment failure)
Trigger a payment-failed email sequence when Stripe fires a invoice.payment_failed event.
import { createClient } from '@mailmodo/sdk';
import type { Request, Response } from 'express';
import Stripe from 'stripe';
const mailmodo = createClient({ apiKey: process.env.MAILMODO_API_KEY });
export async function stripeWebhookHandler(req: Request, res: Response) {
const event = req.body as Stripe.Event;
if (event.type === 'invoice.payment_failed') {
const invoice = event.data.object as Stripe.Invoice;
const email = invoice.customer_email;
if (email) {
await mailmodo.track('user.payment_failed', {
email,
invoiceId: invoice.id,
amount: invoice.amount_due / 100,
currency: invoice.currency.toUpperCase(),
attemptCount: invoice.attempt_count,
});
}
}
res.sendStatus(200);
}Example 3 — Background job (churn risk scoring)
Run a nightly job to identify at-risk users and fire targeted events.
import { createClient, MailmodoAPIError } from '@mailmodo/sdk';
const mailmodo = createClient({
apiKey: process.env.MAILMODO_API_KEY,
maxRetries: 1, // Fail fast in batch jobs
});
async function flagChurnRiskUsers(users: Array<{ email: string; healthScore: number }>) {
for (const user of users) {
if (user.healthScore < 30) {
try {
await mailmodo.track('user.churn_risk', {
email: user.email,
healthScore: user.healthScore,
});
await mailmodo.identify(user.email, {
churnRisk: true,
healthScore: user.healthScore,
flaggedAt: new Date().toISOString(),
});
} catch (err) {
if (err instanceof MailmodoAPIError) {
console.warn(`Skipped ${user.email}: API error ${err.statusCode}`);
} else {
throw err; // Re-throw unexpected errors
}
}
}
}
}TypeScript Support
The package ships with full TypeScript types — no @types/* package needed. Both ESM (.mjs) and CJS (.cjs) builds include .d.ts declaration files.
Key exported types
import type {
EventName, // KnownEventName | (string & {}) — open union with autocomplete
KnownEventName, // The 13 pre-defined event name literals
TrackProperties, // { email: string; [key: string]: PropertyValue }
IdentifyProperties, // { [key: string]: PropertyValue }
TrackResponse, // { received: boolean; emailsSent?: number; ... }
IdentifyResponse, // { email: string; properties: {...}; status: 'active' | 'suppressed' }
MailmodoClientConfig,
PropertyValue, // string | number | boolean | null
} from '@mailmodo/sdk';How EventName autocompletion works
EventName is defined as KnownEventName | (string & {}). This is a standard TypeScript pattern that preserves literal autocomplete on an open union — your editor will suggest the 13 known event names, but any string is still accepted without a type error.
// ✅ Autocompleted
await track('user.signup', { email: 'alice@example.com' });
// ✅ Custom events also accepted
await track('user.custom_action', { email: 'alice@example.com' });PropertyValue constraint
All custom properties passed to track() and identify() must be string | number | boolean | null. Objects, arrays, and undefined are not accepted, ensuring your payloads remain serialisable and type-safe.
await identify('alice@example.com', {
plan: 'pro', // ✅ string
seats: 10, // ✅ number
verified: true, // ✅ boolean
deletedAt: null, // ✅ null (clear a property)
tags: ['a', 'b'], // ❌ Type error — arrays not allowed
});Error Handling
The SDK throws structured, typed errors. All error classes extend MailmodoError, so you can catch broadly or narrowly.
Error classes
| Class | Extends | Extra fields | When thrown |
|---|---|---|---|
MailmodoError |
Error |
— | Base class; also thrown for invalid input (e.g., missing email) |
MailmodoAPIError |
MailmodoError |
statusCode: number |
API returned a non-retriable HTTP error (4xx, or 5xx after all retries exhausted) |
MailmodoNetworkError |
MailmodoError |
cause?: unknown |
Network failure or request timeout |
Recommended error handling pattern
import {
track,
MailmodoError,
MailmodoAPIError,
MailmodoNetworkError,
} from '@mailmodo/sdk';
try {
await track('user.signup', { email: 'alice@example.com' });
} catch (err) {
if (err instanceof MailmodoAPIError) {
// The API rejected the request (e.g., 400 Bad Request, 401 Unauthorized)
console.error(`API error ${err.statusCode}: ${err.message}`);
if (err.statusCode === 401) {
// Handle invalid API key
}
} else if (err instanceof MailmodoNetworkError) {
// Network is down, request timed out, or all retries exhausted
console.error('Network error:', err.message, err.cause);
} else if (err instanceof MailmodoError) {
// Invalid input — e.g., missing or empty email
console.error('Invalid call:', err.message);
} else {
throw err; // Re-throw anything unexpected
}
}instanceof across module systems
The SDK uses Object.setPrototypeOf(this, new.target.prototype) in all error constructors, which ensures instanceof works correctly even when the SDK is loaded as CJS in a mixed ESM/CJS bundle.
Retry & Timeout Behaviour
The SDK automatically retries requests on transient failures. No configuration is required.
| Setting | Default | Config key |
|---|---|---|
| Timeout per attempt | 10,000 ms |
timeout |
| Max retry attempts | 3 |
maxRetries |
| Backoff base | 500 ms |
— |
| Backoff cap | 30,000 ms |
— |
Retriable conditions
Retries are triggered for:
- HTTP status codes:
408,429,500,502,503,504 - Network errors (DNS failure, connection refused, etc.)
- Request timeouts (the
AbortControllerfires aftertimeoutms)
Non-retriable conditions (thrown immediately, no retry):
4xxstatus codes other than408and429- Invalid input (missing email, missing API key)
Backoff strategy
Each retry waits using exponential backoff with jitter:
delay = min(500ms × 2^attempt, 30s) + random jitter up to 500ms
On 429 Too Many Requests, the SDK respects the server's Retry-After header (both the seconds integer and the HTTP-date formats), capped at 30,000 ms.
To disable retries entirely:
const mailmodo = createClient({ apiKey: '...', maxRetries: 0 });License
MIT Mailmodo