---
title: Node.js SDK
description: "Install @mailfully/node, construct the client, handle the { data, error } result tuple, and map every SDK method to its API endpoint."
---

> **For AI agents:** the complete documentation index is at [llms.txt](/docs/llms.txt). Append `.md` to any page URL for its markdown version.

`@mailfully/node` is a typed wrapper over the global `fetch` API. It has zero runtime dependencies, ships as ESM only (no CommonJS build), and requires Node.js 24 or later.

## Install

```bash
npm install @mailfully/node
```

Also works with `pnpm add @mailfully/node` or `yarn add @mailfully/node`.

## Create a client

```typescript
import { Mailfully } from "@mailfully/node";

const mailfully = new Mailfully({
  apiKey: process.env.MAILFULLY_API_KEY ?? "",
});
```

The SDK never reads environment variables itself and never logs the key. Pass it explicitly. Set it in your shell:

```bash
export MAILFULLY_API_KEY=mf_live_xxxxxxxxxxxx
```

| Option | Type | Required | Behavior |
|---|---|---|---|
| `apiKey` | `string` | yes | Sent as `Authorization: Bearer <apiKey>` on every request. Missing or empty, the constructor throws ``TypeError("Mailfully: `apiKey` is required.")`` — an unset env var fails at construction, not on your first call. |
| `baseUrl` | `string` | no | Defaults to `https://api.mailfully.com` (exported as `DEFAULT_BASE_URL`). Trailing slashes are stripped. |
| `fetch` | `typeof fetch` | no | Defaults to the global `fetch`. Inject your own for tests or proxies. |

The constructor `TypeError` is the only exception the SDK ever throws. Every method returns a result tuple instead.

Resources hang off the client instance: `mailfully.emails`, `mailfully.domains`, `mailfully.apiKeys`, `mailfully.webhooks`, `mailfully.suppressions`, and `mailfully.analytics`. The resource classes are not exported. Reach them through the client.

## Handle results

Every method resolves to a `{ data, error }` tuple. Exactly one side is non-null:

```typescript
type MailfullyResult<T> =
  | { data: T; error: null }
  | { data: null; error: MailfullyError };
```

Check `error !== null` before touching `data`, and TypeScript narrows the other side for you:

```typescript
const { data, error } = await mailfully.emails.send({
  from: "orders@mail.acme.com",
  to: "you@yourcompany.com", // replace with an address you own — example.com bounces
  subject: "Your order shipped",
  html: "<p>Your order is on its way.</p>",
  tags: [{ name: "category", value: "order-shipped" }],
});

if (error !== null) {
  if (error.statusCode === null) {
    // The request never reached the API: DNS failure, connection reset, abort.
    // The underlying fetch error is on error.cause.
    console.error(`Network failure: ${error.message}`);
  } else {
    // The API responded with a non-2xx status.
    console.error(
      `Send failed (${error.statusCode} ${error.type}): ${error.message}`,
    );
  }
  return;
}

console.log(data.id); // "01J9ZC3AB8XQ4RW2N7VKT5EMHD" — a bare ULID, no prefix
```

### MailfullyError

`MailfullyError` extends `Error`:

| Property | Type | Meaning |
|---|---|---|
| `message` | `string` | The API envelope `message` when present, otherwise fallback text like `Request failed with status 500 Internal Server Error.` |
| `statusCode` | `number \| null` | HTTP status. `null` means a transport failure — the request never got a response. |
| `type` | `string \| null` | Machine-readable error code, e.g. `validation_error`. `null` when the response body wasn't the canonical error envelope. |
| `param` | `string \| null` | The offending field path. Set on validation errors. |
| `cause` | `unknown` | The underlying error when available, e.g. the rejected `fetch` error on transport failures. |

Non-2xx responses are parsed from the API's canonical envelope `{ error: { type, message, param } }`. A 2xx response with an empty or non-JSON body resolves with `data: null` rather than an error (relevant for 204-style responses).

The SDK does not validate response bodies at runtime; success payloads are cast to the documented types. See [Errors](/concepts/errors) for the full catalog of error `type` values.

## Methods

The SDK exposes 27 methods across six resources. Each maps to exactly one endpoint.

<Warning>
`webhooks.*`, `apiKeys.*`, `suppressions.create`, and `suppressions.delete` call endpoints that require the `manage:webhooks`, `manage:keys`, and `manage:suppressions` scopes. API keys cannot carry those scopes today, so these methods fail with `403 insufficient_scope` when the client is constructed with an `mf_` API key. Manage webhooks, API keys, and suppression entries in the [dashboard](https://dashboard.mailfully.com) instead.
</Warning>

### emails

| Method | Endpoint | Returns |
|---|---|---|
| `emails.send(input, options?)` | [`POST /v1/emails`](/api-reference/emails/send) | `{ id }` (202) |
| `emails.batch(inputs, options?)` | [`POST /v1/emails/batch`](/api-reference/emails/send-batch) | `{ data: [{ id }, …] }` (202); up to 100 messages |
| `emails.get(id)` | [`GET /v1/emails/:id`](/api-reference/emails/get) | The full email, including `html` and `text` |
| `emails.list(query?)` | [`GET /v1/emails`](/api-reference/emails/list) | `{ data, has_more, next_cursor }` |
| `emails.cancel(id)` | [`POST /v1/emails/:id/cancel`](/api-reference/emails/cancel) | The canceled email |
| `emails.reschedule(id, input)` | [`PATCH /v1/emails/:id`](/api-reference/emails/reschedule) | The updated email |
| `emails.events(id)` | [`GET /v1/emails/:id/events`](/api-reference/emails/list-events) | `{ data: [{ type, event_at, detail }, …] }` |

`emails.list` accepts `{ limit, cursor, status, domain, tag, recipient, from, to }`. `emails.reschedule` takes `{ scheduledAt }`, sent on the wire as `scheduled_at`.

### domains

| Method | Endpoint | Returns |
|---|---|---|
| `domains.create(input)` | [`POST /v1/domains`](/api-reference/domains/create) | The domain plus its DNS `records` (201) |
| `domains.list()` | [`GET /v1/domains`](/api-reference/domains/list) | `{ data: [domain, …] }` |
| `domains.get(id)` | [`GET /v1/domains/:id`](/api-reference/domains/get) | The domain plus its DNS `records` |
| `domains.verify(id)` | [`POST /v1/domains/:id/verify`](/api-reference/domains/verify) | The domain with refreshed verification status |
| `domains.tracking(id, input)` | [`POST /v1/domains/:id/tracking`](/api-reference/domains/update-tracking) | The domain; `records` holds only the tracking CNAME when enabling and is empty when disabling |

`domains.create` takes `{ name, mailFromPrefix? }`; `domains.tracking` takes `{ enabled: boolean }`.

### apiKeys

Session-only today. See the warning above.

| Method | Endpoint | Returns |
|---|---|---|
| `apiKeys.list()` | [`GET /v1/api-keys`](/api-reference/api-keys/list) | `{ data: [key, …] }` — never includes plaintext keys |
| `apiKeys.create(input)` | [`POST /v1/api-keys`](/api-reference/api-keys/create) | The new key with plaintext `key`, returned exactly once (201) |
| `apiKeys.revoke(id)` | [`POST /v1/api-keys/:id/revoke`](/api-reference/api-keys/revoke) | `{ id, revoked_at }` |
| `apiKeys.rotate(id)` | [`POST /v1/api-keys/:id/rotate`](/api-reference/api-keys/rotate) | The replacement key plus `replaces_id` and `old_key_expires_at` |

### webhooks

Session-only today. See the warning above.

| Method | Endpoint | Returns |
|---|---|---|
| `webhooks.create(input)` | [`POST /v1/webhooks`](/api-reference/webhooks/create) | The webhook with `signing_secret`, returned exactly once (201) |
| `webhooks.list()` | [`GET /v1/webhooks`](/api-reference/webhooks/list) | `{ data: [webhook, …] }` — no `signing_secret` |
| `webhooks.delete(id)` | [`DELETE /v1/webhooks/:id`](/api-reference/webhooks/delete) | `{ object: "webhook", id, deleted: true }` |
| `webhooks.deliveries(id, query?)` | [`GET /v1/webhooks/:id/deliveries`](/api-reference/webhooks/list-deliveries) | Paginated delivery attempts |

`webhooks.create` takes `{ url, events }`, where `events` is a subset of the event types exported as `MAILFULLY_EVENT_TYPES`. The filter accepts `email.suppressed`, but that type is never delivered as a webhook today. See [Webhooks](/guides/webhooks).

### suppressions

`suppressions.list` works with the `read:suppressions` scope; `create` and `delete` are session-only today (see the warning above).

| Method | Endpoint | Returns |
|---|---|---|
| `suppressions.list(query?)` | [`GET /v1/suppressions`](/api-reference/suppressions/list) | `{ data, has_more, next_cursor }` |
| `suppressions.create(input)` | [`POST /v1/suppressions`](/api-reference/suppressions/create) | The suppression entry |
| `suppressions.delete(id)` | [`DELETE /v1/suppressions/:id`](/api-reference/suppressions/delete) | `{ id, deleted: true }` |

`suppressions.list` accepts `{ limit, cursor, email, reason }`; `email` is an exact match.

### analytics

| Method | Endpoint | Returns |
|---|---|---|
| `analytics.daily(query?)` | [`GET /v1/analytics/daily`](/api-reference/analytics/daily) | Daily rollup rows |
| `analytics.byDomain(query?)` | [`GET /v1/analytics/by-domain`](/api-reference/analytics/by-domain) | Per-domain daily rows |
| `analytics.byTag(query?)` | [`GET /v1/analytics/by-tag`](/api-reference/analytics/by-tag) | Per-tag daily rows |
| `analytics.externalReputation()` | [`GET /v1/analytics/external-reputation`](/api-reference/analytics/external-reputation) | Gmail spam-rate data per domain |

The rollup queries take `{ from, to }` as `YYYY-MM-DD` days, plus `domain` or `tag` on the respective endpoints. Every rollup row carries the counters `sent`, `delivered`, `bounced`, `complained`, `opened`, `clicked`.

## Idempotency keys

`emails.send` and `emails.batch` accept an options bag as their second argument. Set `idempotencyKey` and the SDK forwards it as the `Idempotency-Key` header. Repeating the request with the same key returns the original result instead of sending twice:

```typescript
const { data, error } = await mailfully.emails.send(input, {
  idempotencyKey: crypto.randomUUID(),
});
```

No other method takes request options. See [Idempotency](/concepts/idempotency) for key constraints and replay semantics.

## Field naming

Inputs are camelCase; the SDK maps them to the API's snake_case wire fields and drops omitted optionals entirely (it never sends `null`):

| SDK input field | Wire field |
|---|---|
| `replyTo` | `reply_to` |
| `scheduledAt` | `scheduled_at` |
| `mailFromPrefix` | `mail_from_prefix` |
| `domainId` | `domain_id` |
| everything else | unchanged |

Responses are **not** camelized: you get the raw snake_case wire shapes (`created_at`, `last_event`, `has_more`, `next_cursor`, …). The one exception is `analytics.externalReputation()`, whose payload is camelCase on the wire itself (`gmailSpamRate`, `observedAt`, `worstGmailSpamRate`).

## What the SDK doesn't cover

- **No `templates` resource.** Template *sending* works — pass `template: { id: "tmpl_...", variables: { ... } }` to `emails.send`. Template management (create, update, delete) goes through the [API directly](/api-reference/templates/create).
- **No `webhooks.get(id)`.** `GET /v1/webhooks/:id` exists in the API but has no SDK method.
- **No dead-letter methods.** [`GET /v1/webhook-dead-letters`](/api-reference/webhooks/list-dead-letters) and [`POST /v1/webhook-dead-letters/:id/replay`](/api-reference/webhooks/replay-dead-letter) have no SDK methods.
- **No signature-verification helper.** The SDK exports the event-type constants only; verify inbound webhook signatures yourself (see the [webhooks guide](/guides/webhooks)).
- **No automatic retries, timeouts, or pagination.** Each call is a single `fetch` with no retry, no timeout or `AbortSignal` option, and no auto-pagination. Loop on `next_cursor` yourself.
