Node.js SDK

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

@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

npm install @mailfully/node

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

Create a client

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:

export MAILFULLY_API_KEY=mf_live_xxxxxxxxxxxx
OptionTypeRequiredBehavior
apiKeystringyesSent 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.
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.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, 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 27 methods across six resources. Each maps to exactly one endpoint.

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 instead.

emails

MethodEndpointReturns
emails.send(input, options?)POST /v1/emails{ id } (202)
emails.batch(inputs, options?)POST /v1/emails/batch{ data: [{ id }, …] } (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 { limit, cursor, status, domain, tag, recipient, from, to }. emails.reschedule takes { scheduledAt }, sent on the wire as scheduled_at.

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 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.

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

webhooks

Session-only today. See the warning above.

MethodEndpointReturns
webhooks.create(input)POST /v1/webhooksThe webhook with signing_secret, returned exactly once (201)
webhooks.list()GET /v1/webhooks{ data: [webhook, …] } — no signing_secret
webhooks.delete(id)DELETE /v1/webhooks/:id{ object: "webhook", id, deleted: true }
webhooks.deliveries(id, query?)GET /v1/webhooks/:id/deliveriesPaginated 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.

suppressions

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

MethodEndpointReturns
suppressions.list(query?)GET /v1/suppressions{ data, has_more, next_cursor }
suppressions.create(input)POST /v1/suppressionsThe suppression entry
suppressions.delete(id)DELETE /v1/suppressions/:id{ id, deleted: true }

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

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; the SDK maps them to the API's snake_case wire fields and drops omitted optionals entirely (it never sends null):

SDK input fieldWire field
replyToreply_to
scheduledAtscheduled_at
mailFromPrefixmail_from_prefix
domainIddomain_id
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

  • No templates resource. Template sending works — pass template: { id: "tmpl_...", variables: { ... } } to emails.send. Template management (create, update, delete) goes through the API directly.
  • 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 and POST /v1/webhook-dead-letters/:id/replay 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).
  • 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.