Mailfully logo

Node.js SDK

Install mailfully, construct the client, handle the { data, error } result tuple, and map every SDK method to its API endpoint.

mailfully 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

npm install mailfully

Also works with pnpm add mailfully or yarn add mailfully.

One package covers both ways of calling the API: installed as a dependency it gives you the client documented here, and installed globally (npm install -g mailfully) it puts the mailfully command on your PATH. There is nothing extra to install for either.

The client was previously published as @mailfully/node, which has been retired. Migrating is two find-and-replaces with no API change — swap npm install @mailfully/node for npm install mailfully, and from "@mailfully/node" for from "mailfully". Every class, method, type, and { data, error } result is identical.

Create a client

import { Mailfully } from "mailfully";

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:

export MAILFULLY_API_KEY=mf_live_xxxxxxxxxxxx
OptionTypeRequiredBehavior
apiKeystringyesSent as Authorization: Bearer <apiKey> on every request. If it is missing or empty, the constructor throws TypeError("Mailfully: `apiKey` is required."), so an unset env var fails at construction rather than on your first call.
baseUrlstringnoDefaults to https://api.mailfully.com (exported as DEFAULT_BASE_URL). Trailing slashes are stripped.
fetchtypeof fetchnoDefaults 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.templates, 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:

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:

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 with no prefix

MailfullyError

MailfullyError extends Error:

PropertyTypeMeaning
messagestringThe API envelope message when present, otherwise fallback text like Request failed with status 500 Internal Server Error.
statusCodenumber | nullHTTP status. null means a transport failure: the request never got a response.
typestring | nullMachine-readable error code, e.g. validation_error. null when the response body wasn't the canonical error envelope.
paramstring | nullThe offending field path. Set on validation errors.
causeunknownThe 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 for the full catalog of error type values.

Methods

The SDK exposes 36 methods across seven resources, and each maps to exactly one endpoint. The tables below list 26. The other 10 (four apiKeys.*, four webhooks.*, suppressions.create and suppressions.delete) return 403 insufficient_scope with any API key; use the dashboard for those.

webhooks.create takes { url, events }, where events is a subset of the event types exported as MAILFULLY_EVENT_TYPES; email.received is in the list from 1.2.0. The filter accepts email.suppressed, but that type is never delivered as a webhook. See Webhooks.

emails

MethodEndpointReturns
emails.send(input, options?)POST /v1/emailsThe accepted message (202) — see below
emails.batch(inputs, options?)POST /v1/emails/batch{ data: [accepted, …] } (202); up to 100 messages
emails.get(id)GET /v1/emails/:idThe full email, including html and text
emails.list(query?)GET /v1/emails{ data, has_more, next_cursor }
emails.cancel(id)POST /v1/emails/:id/cancelThe canceled email
emails.reschedule(id, input)PATCH /v1/emails/:idThe updated email
emails.events(id)GET /v1/emails/:id/events{ data: [{ type, event_at, detail }, …] }

emails.list accepts these filters, plus limit and cursor:

FieldMeaning
statusMessage status
domainRecipient domain
tagTag value
recipientExact to address
typetransactional or marketing
qFree-text search, 1 to 200 characters: subject and from-address substrings, an exact message id, or, when the term looks like an address, an exact recipient
from, toISO 8601 bounds on created_at

emails.reschedule takes { scheduledAt }, sent on the wire as scheduled_at.

The accepted message

emails.send resolves to the accepted message, and emails.batch to one per message. A 202 means the API took responsibility for the message, not that mail went out:

{
  object: "email",
  id: "01J9ZC3AB8XQ4RW2N7VKT5EMHD",
  status: "queued",          // or "canceled"
  type: "transactional",     // or "marketing"
  to: ["you@yourcompany.com"], cc: [], bcc: [],
  from: "orders@mail.acme.com",
  subject: "Your order shipped",
  scheduled_at: null,
  created_at: "2026-09-13T20:15:04.881Z"
}

Check status. It is "canceled" when the recipients were suppressed, which means the request succeeded and nothing will be sent. A fully suppressed to cancels the send even when a cc or bcc survives, because a suppressed primary recipient is never replaced by promoting one. Treating a 202 as "delivered" will silently miss this.

Pass type: "marketing" for campaign mail (the default is "transactional"). A marketing send accepts exactly one recipient across to, cc and bcc, because the one-click unsubscribe link must identify one person. More than one is rejected with 422 marketing_single_recipient.

Received mail

emails.receiving reads mail you receive. It needs mailfully 1.2.0 or later and a key with the read:inbound scope; send and read:emails keys get 403 insufficient_scope.

MethodEndpointReturns
emails.receiving.list(query?)GET /v1/emails/receiving{ data, has_more, next_cursor, prev_cursor }
emails.receiving.get(id, options?)GET /v1/emails/receiving/:idThe received email, including html, text, headers, and a raw download link
emails.receiving.attachments.list(emailId)GET /v1/emails/receiving/:id/attachments{ data: [attachment, …] }, each with a download_url
emails.receiving.attachments.get(emailId, attachmentId)GET /v1/emails/receiving/:id/attachments/:attachmentIdOne attachment with its download_url

emails.receiving.list accepts { limit, after, before, q, domain, hasAttachments, spam }. Pass next_cursor as after for older mail and prev_cursor as before for newer mail. hasAttachments and spam filter only when true. emails.receiving.get takes { htmlFormat: "data_uri" | "cid" }, sent as html_format.

const { data, error } = await mailfully.emails.receiving.list({
  domain: "mail.acme.com",
  hasAttachments: true,
});
if (error) throw new Error(`${error.type}: ${error.message}`);

for (const email of data.data) {
  console.log(email.id, email.from, email.subject);
}

templates

MethodEndpointReturns
templates.create(input)POST /v1/templatesThe new template (201)
templates.list()GET /v1/templates{ data: [template, …] }
templates.get(id)GET /v1/templates/:idThe template
templates.update(id, input)PATCH /v1/templates/:idThe updated template
templates.delete(id)DELETE /v1/templates/:id{ id, deleted: true }

templates.create and templates.update take { name, html, text, variablesSchema }. templates.list takes no argument.

To send with a template, pass template: { id: "tmpl_...", variables: { ... } } to emails.send instead of composing html yourself. See Templates.

domains

MethodEndpointReturns
domains.create(input)POST /v1/domainsThe domain plus its DNS records (201)
domains.list()GET /v1/domains{ data: [domain, …] }
domains.get(id)GET /v1/domains/:idThe domain plus its DNS records
domains.verify(id)POST /v1/domains/:id/verifyThe domain with refreshed verification status
domains.tracking(id, input)POST /v1/domains/:id/trackingThe domain; records is always empty, since tracking needs no DNS record

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

suppressions

MethodEndpointReturns
suppressions.list(query?)GET /v1/suppressions{ data, has_more, next_cursor }

suppressions.list accepts { limit, cursor, email, reason }; email is an exact match. It works with the read:suppressions scope.

analytics

MethodEndpointReturns
analytics.daily(query?)GET /v1/analytics/dailyDaily rollup rows
analytics.byDomain(query?)GET /v1/analytics/by-domainPer-domain daily rows
analytics.byTag(query?)GET /v1/analytics/by-tagPer-tag daily rows
analytics.externalReputation()GET /v1/analytics/external-reputationGmail 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:

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

No other method takes request options. See Idempotency for key constraints and replay semantics.

Field naming

Inputs are camelCase and the SDK maps them to the API's snake_case wire fields. Fields you leave out are not sent. An explicit null is sent, so use it to clear html or text in templates.update:

SDK input fieldWire field
replyToreply_to
scheduledAtscheduled_at
mailFromPrefixmail_from_prefix
domainIddomain_id
hasAttachmentshas_attachments
htmlFormathtml_format
variablesSchemavariables_schema
everything elseunchanged

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

  • webhooks.get(id). GET /v1/webhooks/:id exists in the API but has no SDK method.
  • Dead letters: GET /v1/webhook-dead-letters and POST /v1/webhook-dead-letters/:id/replay.
  • Deleting a domain, restarting a failed verification, and turning receiving on or off.
  • Listing refusals.
  • Verifying webhook signatures. The SDK exports the event-type constants only, so verify signatures yourself (see the webhooks guide).
  • Retries, timeouts and pagination. Each call is a single fetch with no retry, no timeout or AbortSignal option and no auto-pagination. Loop on next_cursor yourself.

What's next

  • CLI — the same API from your terminal, built on this SDK
  • Quickstart — send your first message
  • Idempotency — safe retries
  • Errors — the full type catalog