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

npm install @mailneo/sdk
Current version: 0.1.0. Source on GitHub, package on npm. The types are generated from the API spec at the release commit, so the compiler knows exactly what every endpoint accepts and returns.

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 support

Pagination

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 drain

Incremental 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 completes

Error 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 maxRetries is 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-After is respected exactly, whether seconds or an HTTP-date. If it exceeds maxRetryAfterMs (default 60 s) the SDK throws instead of stalling your process.
  • Otherwise: full-jitter exponential backoff — retryBaseMs 500 ms, doubling, capped at retryCapMs 30 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

SDK 0.1.0 was generated from the API spec before the send scopes shipped, so it has no 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.