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
| Option | Type | Required | Behavior |
|---|---|---|---|
apiKey | string | yes | Sent 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. |
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.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:
| 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 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
| Method | Endpoint | Returns |
|---|---|---|
emails.send(input, options?) | POST /v1/emails | The 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/:id | The full email, including html and text |
emails.list(query?) | GET /v1/emails | { data, has_more, next_cursor } |
emails.cancel(id) | POST /v1/emails/:id/cancel | The canceled email |
emails.reschedule(id, input) | PATCH /v1/emails/:id | The updated email |
emails.events(id) | GET /v1/emails/:id/events | { data: [{ type, event_at, detail }, …] } |
emails.list accepts these filters, plus limit and cursor:
| Field | Meaning |
|---|---|
status | Message status |
domain | Recipient domain |
tag | Tag value |
recipient | Exact to address |
type | transactional or marketing |
q | Free-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, to | ISO 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.
| Method | Endpoint | Returns |
|---|---|---|
emails.receiving.list(query?) | GET /v1/emails/receiving | { data, has_more, next_cursor, prev_cursor } |
emails.receiving.get(id, options?) | GET /v1/emails/receiving/:id | The 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/:attachmentId | One 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
| Method | Endpoint | Returns |
|---|---|---|
templates.create(input) | POST /v1/templates | The new template (201) |
templates.list() | GET /v1/templates | { data: [template, …] } |
templates.get(id) | GET /v1/templates/:id | The template |
templates.update(id, input) | PATCH /v1/templates/:id | The 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
| Method | Endpoint | Returns |
|---|---|---|
domains.create(input) | POST /v1/domains | The domain plus its DNS records (201) |
domains.list() | GET /v1/domains | { data: [domain, …] } |
domains.get(id) | GET /v1/domains/:id | The domain plus its DNS records |
domains.verify(id) | POST /v1/domains/:id/verify | The domain with refreshed verification status |
domains.tracking(id, input) | POST /v1/domains/:id/tracking | The domain; records is always empty, since tracking needs no DNS record |
domains.create takes { name, mailFromPrefix? }; domains.tracking takes { enabled: boolean }.
suppressions
| Method | Endpoint | Returns |
|---|---|---|
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
| Method | Endpoint | Returns |
|---|---|---|
analytics.daily(query?) | GET /v1/analytics/daily | Daily rollup rows |
analytics.byDomain(query?) | GET /v1/analytics/by-domain | Per-domain daily rows |
analytics.byTag(query?) | GET /v1/analytics/by-tag | Per-tag daily rows |
analytics.externalReputation() | GET /v1/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:
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 field | Wire field |
|---|---|
replyTo | reply_to |
scheduledAt | scheduled_at |
mailFromPrefix | mail_from_prefix |
domainId | domain_id |
hasAttachments | has_attachments |
htmlFormat | html_format |
variablesSchema | variables_schema |
| 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
webhooks.get(id).GET /v1/webhooks/:idexists in the API but has no SDK method.- Dead letters:
GET /v1/webhook-dead-lettersandPOST /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
fetchwith no retry, no timeout orAbortSignaloption and no auto-pagination. Loop onnext_cursoryourself.
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
typecatalog
