Resources
TypeScript SDK
@mailneo/sdk is a typed client for the whole v2 API, generated from the OpenAPI spec — zero runtime dependencies, Node 18+ and modern browsers, ESM and CommonJS. This page covers what the SDK adds on top of the API; for the API itself, start with the API reference.
Install
Authentication
Construct one client with an API key or an OAuth 2.1 access token — exactly one is required. One namespace per API tag: subscribers, lists, segments, contacts, campaigns, newsletters, templates, suppressions, analytics, accounts, webhooks and system, plus the client.me() and client.usage() shortcuts.
import { MailneoClient } from "@mailneo/sdk";
const client = new MailneoClient({ apiKey: process.env.MAILNEO_API_KEY! });
// or, with an OAuth 2.1 access token:
// const client = new MailneoClient({ accessToken });
// Who am I? /me needs no scope, so it always works.
const me = await client.me();
console.log(me.data.scopes);Constructor options beyond credentials: baseUrl (default https://api.mailneo.co/api/v2), fetch (injectable for tests), maxRetries, retryBaseMs, retryCapMs, maxRetryAfterMs and defaultHeaders.
The envelope
Methods return the API's envelope as-is: { data, meta }. Nothing is unwrapped, because meta carries things you need: meta.request_id is what Mailneo support asks for, and on a dry_run call data is null while meta.impact describes what would have happened.
const created = await client.subscribers.create(
{ email: "ada@example.com" },
{ dryRun: true }
);
created.data; // null on a dry run —
created.meta.impact; // — meta.impact says what WOULD have happened
created.meta.request_id; // on every response; quote it to supportPagination
List methods return a lazy CursorPage. Nothing is fetched until you consume it, and pages are fetched one at a time as you iterate. The cursor parameter is deliberately not accepted on list calls — the iterator owns the walk.
const page = client.subscribers.list({ limit: 100 });
for await (const item of page) { /* items across every page */ }
for await (const envelope of page.pages()) { /* raw page envelopes */ }
await page.firstPage(); // exactly one request
await page.toArray(500); // bounded drainIncremental sync with synced_through
For polling only what changed, read meta.synced_through from the final page and pass it back as updated_since on the next run. The server deliberately omits the watermark mid-walk, so this only works at the envelope level:
// synced_through appears only on the FINAL page (has_more: false),
// which is why the envelope-level pages() iterator exists.
let watermark: string | undefined = loadWatermark();
for await (const page of client.subscribers.list({ updated_since: watermark }).pages()) {
for (const row of page.data) upsertById(row); // expect occasional re-sees
if (!page.meta.has_more) watermark = page.meta.synced_through;
}
saveWatermark(watermark); // only after the walk completesError handling
Non-2xx responses throw one of three typed errors. MailneoApiError carries the API's envelope fields — status, type, code, message, param, docUrl, requestId and retryAfterMs. A non-2xx whose body was not the envelope becomes MailneoHttpError with no fabricated code, and a failed fetch becomes MailneoConnectionError.
import { MailneoApiError, MailneoHttpError, MailneoConnectionError } from "@mailneo/sdk";
try {
await client.segments.get("seg_does_not_exist");
} catch (error) {
if (error instanceof MailneoApiError) {
// The API's error envelope. Branch on `code` — it is the stable contract.
console.error(error.status, error.code, error.requestId);
} else if (error instanceof MailneoHttpError) {
// Non-2xx whose body was NOT the envelope (proxy pages, HTML).
} else if (error instanceof MailneoConnectionError) {
// fetch itself failed.
} else {
throw error;
}
}Idempotency on writes
Every write accepts per-call options { idempotencyKey, dryRun, signal, headers }. The key is sent as the Idempotency-Key header: retrying with the same key replays the first response instead of repeating the work, and reusing a key with a different body is rejected. Keys are remembered for 24 hours.
// Any write accepts an Idempotency-Key; with one, retrying is safe.
await client.subscribers.create(
{ email: "ada@example.com" },
{ idempotencyKey: "create-ada-2026-08-04" }
);
// Where the spec marks idempotency REQUIRED, the compiler enforces it:
// contacts.bulkUpsert and webhooks.replayDelivery do not compile without a key.
await client.contacts.bulkUpsert(
{ contacts: [{ email: "grace@example.com" }] },
{ idempotencyKey: "bulk-2026-08-04" }
);Retries
- Retried: HTTP 429, 500, 502, 503, 504, and connection failures. Default
maxRetriesis 2. - A request is only retried when repeating it is safe: idempotent methods (GET/PUT/DELETE), or a write carrying an
Idempotency-Key. A POST or PATCH without a key is never retried — pass{ idempotencyKey }to opt in. Retry-Afteris respected exactly, whether seconds or an HTTP-date. If it exceedsmaxRetryAfterMs(default 60 s) the SDK throws instead of stalling your process.- Otherwise: full-jitter exponential backoff —
retryBaseMs500 ms, doubling, capped atretryCapMs30 s.
Worked examples
List enabled subscribers
for await (const subscriber of client.subscribers.list({ status: "ENABLED" })) {
console.log(subscriber.email, subscriber.first_name);
}Create and tag a subscriber
const created = await client.subscribers.create(
{ email: "ada@example.com", first_name: "Ada", tags: ["vip"] },
{ idempotencyKey: "create-ada-2026-08-04" }
);
if (created.data) {
await client.lists.addSubscribers("list_123", {
subscriber_ids: [created.data.id],
});
}Build a segment
const segment = await client.segments.create({
name: "VIPs",
conditions: {
operator: "AND",
conditions: [{ field: "tags", operator: "contains", value: "vip" }],
},
});
if (segment.data) {
const count = await client.segments.count(segment.data.id);
console.log(count.data);
}Read campaign analytics
// Completed campaigns, most recently updated first.
const recent = await client.campaigns.list({ status: "COMPLETED" }).firstPage();
for (const campaign of recent.data) {
const overview = await client.analytics.overview({ campaign_id: campaign.id });
const events = overview.data.events;
console.log(campaign.id, events.delivered, events.opened, events.clicked);
}What 0.1.0 does not cover
campaigns.send and no newsletter write or send methods — coming in 0.2.0. Until then, call those endpoints directly over HTTP; the two-step preview → confirm flow is documented in the sending concepts page.