@bernierllc/nevarmail
v0.3.0
Published
Official client for the NevarMail v1 API. Typed, retries 429/5xx, throws NevarMailError on every non-2xx.
Readme
@bernierllc/nevarmail
The official client for the NevarMail v1 API.
Where this lives. This package is published from
bernierllc/tools(packages/core/nevarmail) and is the single source of truth for the client. The NevarMail API it wraps is built inbernierllc/email_demo. Any change to the NevarMail v1 API surface requires a matching change here, in the same unit of work — see that repo'sCLAUDE.md→ "NevarMail SDK (@bernierllc/nevarmail)".
One rule: every non-2xx throws. If a call returns, the API accepted it. There is no
"success" object carrying a failure inside it, and no option to silence errors — the audit
that produced this package found a production integration that swallowed every error, so an
org may have been mailing into a 403 for months with nothing on either side noticing.
Installation
npm install @bernierllc/nevarmailRequires Node 20+ (uses the global fetch). Zero dependencies.
Integration status
| Integration | Status | Why |
|-------------|--------|-----|
| Logger (@bernierllc/logger) | not-applicable | A zero-dependency API client stays zero-dependency. Every failure surfaces as a thrown NevarMailError carrying status, code, requestId and cause — log it in the consumer, with the consumer's logger. |
| NeverHub (@bernierllc/neverhub-adapter) | not-applicable | Core-tier package with no runtime lifecycle to register; NeverHub registration belongs to the service or suite that composes this client. Graceful degradation is therefore not required here. |
Usage
import { NevarMail } from "@bernierllc/nevarmail";
const nevarmail = new NevarMail({ apiKey: process.env.NEVARMAIL_API_KEY! });
const result = await nevarmail.sendEmail({
to: "[email protected]",
subject: "Your quote is ready",
html: "<p>Here it is.</p>",
use_case_id: process.env.NEVARMAIL_USE_CASE_TRANSACTIONAL,
});
console.log(result.id, result.status);Tenancy — one key is one org
A NevarMail API key is the organization. There is no org id in any request body; the server resolves it from the key and ignores anything you send. Two consequences:
- One key per org. A key cannot be pointed at a second org, and a multi-tenant app that sends on behalf of several NevarMail orgs holds several keys.
- Never accept an org identifier from an end user and pass it through. There is nowhere to put it, and the attempt is a sign the integration is modeling tenancy wrong.
Keys, surfaces, and modes
Keys look like nvm_api_live_…, nvm_api_test_…, nvm_mcp_live_…, nvm_mcp_test_….
- Surface (
apivsmcp) — this package speaks REST, so it needs anapikey. Pass anmcpkey and the constructor throws immediately rather than letting the server answer a403 KEY_TYPE_MISMATCHat your first send, which reads like an auth bug. - Mode (
livevstest) — a test key sends only to addresses on the org's test allow-list; anything else is a422 TEST_MODE_RECIPIENT_NOT_ALLOWED. The mode is a property of the key, never a client option:client.modereports what the key is.
const nevarmail = new NevarMail({ apiKey: key });
if (nevarmail.mode === "test") console.warn("test key — sends are allow-list only");Errors
Every failure is a NevarMailError:
import { NevarMail, NevarMailError } from "@bernierllc/nevarmail";
try {
await nevarmail.sendEmail({ to, subject, html });
} catch (err) {
if (err instanceof NevarMailError && err.code === "RECIPIENT_SUPPRESSED") {
// Expected: this person opted out. Not an outage.
return;
}
throw err; // everything else is worth waking someone for
}| Field | Meaning |
|---|---|
| status | HTTP status; 0 when the request never reached the API |
| code | Machine-readable code — branch on this, never on message |
| requestId | Quote it in support; it matches the API's x-request-id |
| retryAfter | Seconds, from Retry-After on a 429 |
| body | The parsed response, for codes newer than your installed version |
Codes worth handling explicitly:
| Code | Status | What it means |
|---|---|---|
| RECIPIENT_SUPPRESSED | 422 | Bounced, complained, or unsubscribed. Do not retry. |
| MARKETING_NOT_ENABLED | 422 | emailType: "marketing" before marketing is configured (Settings → Marketing, incl. a CAN-SPAM address). |
| TEST_MODE_RECIPIENT_NOT_ALLOWED | 422 | Test key, recipient not on the allow-list. |
| CC_BCC_NOT_SUPPORTED_FOR_TEMPLATE | 400 | Templated mail renders per recipient; send one request each. |
| VALIDATION_ERROR | 400 | The body is wrong. Retrying will not help. |
| SCOPE_DENIED | 403 | The key lacks the scope this route needs. |
| KEY_TYPE_MISMATCH | 403 | An MCP key on a REST route (this client refuses it at construction). |
| RATE_LIMITED | 429 | Retried automatically; surfaced if the budget runs out. |
| SCHEDULE_NOT_CANCELLABLE | 409 | The scheduled send already fired or was already cancelled. |
| NETWORK_ERROR | 0 | No response at all — DNS, TLS, or timeout. |
Retries
Retried: 429 (honoring Retry-After), 5xx, and network failures — exponential backoff
with jitter, two extra attempts by default (maxRetries).
Never retried: any other 4xx. It will fail the same way forever, so retrying turns one bad
request into three. Sends are not idempotent; a retry after a 5xx may deliver twice.
Use cases and suppression scope
use_case_id names the message category a send belongs to. It is the difference between
"this person unsubscribed from your newsletter" and "this person can no longer be emailed":
- Name it, and an opt-out scoped to a different category does not block the send.
- Omit it, and every active suppression on that address applies — the strict reading.
Bounces and spam complaints block everything regardless. Only a use_case_id you passed
yourself can narrow a block; a default the server resolves cannot.
Methods
| Method | Route | Scope |
|---|---|---|
| sendEmail | POST /api/v1/email/send | email:send |
| sendEmailToList | POST /api/v1/email/send-to-list | email:send |
| scheduleEmail | POST /api/v1/email/schedule | email:send |
| listScheduledEmails | GET /api/v1/email/schedule | email:send |
| cancelScheduledEmail | DELETE /api/v1/email/schedule/{id} | email:send |
| checkSuppression | GET /api/v1/suppressions/check | org:read |
| getPreferences | GET /api/v1/preferences | contacts:read |
| setPreferences | PUT /api/v1/preferences | contacts:write |
| listTemplates / createTemplate / getTemplate / updateTemplate / deleteTemplate | /api/v1/templates | templates:read / templates:write |
| renderTemplate | POST /api/v1/templates/{id}/render | templates:read |
| listLists / createList / getList | /api/v1/lists | contacts:read / contacts:write |
| addListContacts | POST /api/v1/lists/{id}/contacts | contacts:write |
| listContacts / upsertContacts / updateContact / deleteContact | /api/v1/contacts | contacts:read / contacts:write |
| listSenders / createSender / getSender / updateSender / deleteSender | /api/v1/senders | senders:read / senders:write |
| listDomains / createDomain / getDomain / deleteDomain | /api/v1/domains | domains:read / domains:write |
| getDomainDnsRecords / verifyDomain | /api/v1/domains/{id}/… | domains:read / domains:write |
| listSuppressions / addSuppressions | /api/v1/suppressions | org:read / org:write |
Application-level details worth knowing before you hit them:
- List methods keep pagination.
listTemplatesand friends return{ data, pagination: { page, perPage, total } }— the envelope's pagination is the point of calling a list route, so it is not unwrapped away. Single-resource methods return the payload directly, same as the send methods. - Updates are
PATCH, and partial. Send only the fields you are changing. renderTemplate(id, variables)sends the variables map as the whole body — not wrapped in avariableskey.deleteTemplatenever 404s: an unknown id resolves{ deleted: false }on a 200. Every other delete throws on a missing resource.- Lists have no update or delete — the API exposes none; the SDK does not invent them.
- Suppression scope is cross-field:
scope: "use_case"requiresuse_case_id, anduse_case_idwithscope: "account"is a400. - Managed accounts cannot delete domains —
deleteDomainthrows403 MANAGED_ACCOUNT.
Anything still not covered — analytics, campaigns, webhook endpoints — goes through
request() (or requestPaginated() for list routes), which applies the same auth, envelope
unwrapping, throwing, and retries:
const summary = await nevarmail.request<AnalyticsSummary>("GET", "/api/v1/analytics/summary");Reach for request() rather than writing a second HTTP client beside this one. That is how
the swallowed-error problem happened the first time.
sendEmailToList returns a rollup, not a verdict
A list send that reaches some recipients is a 200 with status: "partial" and a non-zero
failedCount. Check the fields; a 2xx does not mean everyone received it.
const run = await nevarmail.sendEmailToList({ listId, subject, htmlContent });
if (run.status !== "sent") console.error(run.failedCount, run.failures);Scheduling needs a real instant
scheduled_for must carry an explicit offset — 2026-09-01T14:00:00Z or
2026-09-01T10:00:00-04:00. A bare local time is four different moments depending on who
typed it, so the API rejects it rather than guessing. Convert on your side, where the zone is
actually known. Templated scheduled sends are refused: the processor does not render
templates, so accepting one would queue an empty body.
Options
new NevarMail({
apiKey: process.env.NEVARMAIL_API_KEY!,
baseUrl: "https://app.nevarmail.com", // default
timeoutMs: 30_000, // default
maxRetries: 2, // default
fetch: undefined, // inject one for tests or a custom runtime
});