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
| 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:
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:
| 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 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
| Method | Endpoint | Returns |
|---|---|---|
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/: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 { 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 | 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 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 | { data: [key, …] } — never includes plaintext keys |
apiKeys.create(input) | POST /v1/api-keys | The 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/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 | The 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/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.
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 | { data, has_more, next_cursor } |
suppressions.create(input) | POST /v1/suppressions | The 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
| 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; 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
templatesresource. Template sending works — passtemplate: { id: "tmpl_...", variables: { ... } }toemails.send. Template management (create, update, delete) goes through the API directly. - No
webhooks.get(id).GET /v1/webhooks/:idexists in the API but has no SDK method. - No dead-letter methods.
GET /v1/webhook-dead-lettersandPOST /v1/webhook-dead-letters/:id/replayhave 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
fetchwith no retry, no timeout orAbortSignaloption, and no auto-pagination. Loop onnext_cursoryourself.