@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/nevarmail
Server-side only; requires Node 20+ (uses global fetch and crypto.randomUUID). Never expose API credentials in browser code. 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. Waits of 30s or less are retried for you; a longer one (a daily cap) throws at once |
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. |
COMPLIANCE_ADDRESS_REQUIRED | 422 | emailType: "marketing" (or a use case recipients can opt out of) from a project with no postal address. Set it with setProjectAddress. |
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
Suppressions are per project. checkSuppression(email, useCaseId?, projectId?) answers for
one project: projectId, else the project of useCaseId, else the organization's only
project. An organization with several projects gets 400 PROJECT_REQUIRED when neither is
passed. A bounce or spam complaint on a provider account blocks every project; any other
suppression blocks only its own.
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 from a different category's list does not block the send, and a category marked non-suppressable is exempt from opt-outs.
- Omit it, and every active suppression of the project applies — the strict reading.
Only a project-scoped key may omit it on
sendEmail/scheduleEmail: the send runs as that project's default use case. An org-wide key that omits it gets422 USE_CASE_REQUIRED;listUseCases()shows yours.
Bounces, spam complaints and manual suppressions block every category regardless. Only a
use_case_id you passed yourself can narrow a block; a default the server resolves cannot.
sendEmail also takes the use case by slug — useCase: "receipts" — instead of its id (one of
the two, not both). The slug must exist in exactly one project the key can open: unknown is a
404, the same slug in several projects is 400 USE_CASE_AMBIGUOUS (pass use_case_id then).
listUseCases shows the slugs.
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(email, projectId?) — one address's lists in one project; projectId required when the org has more than one | GET /api/v1/preferences | contacts:read |
setPreferences(email, { subscribedListIds, unsubscribeAll, projectId? }) — the complete set of lists to stay on | 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 |
listCampaigns({ projectId?, page?, perPage? }) / getCampaign(id, { projectId? }) — read-only | GET /api/v1/campaigns, GET /api/v1/campaigns/{id} | campaigns:read |
getCampaignAnalytics(id, { projectId? }) — counts plus delivery/open/click/bounce/unsubscribe rates (0 to 1) | GET /api/v1/campaigns/{id}/analytics | analytics:read |
getAnalyticsSummary({ period? } | { start, end }) — org-wide counts and rates for a trailing 7d/30d/90d (default 30d) or an inclusive UTC YYYY-MM-DD range | GET /api/v1/analytics/summary | analytics:read |
getUsage({ start, end }) — metered outbound email counts (billable and test-mode apart) for an inclusive UTC YYYY-MM-DD range of at most 366 days, current hour included; counts only, no cost | GET /api/v1/usage | usage:read |
listLists / createList / getList | /api/v1/lists | contacts:read / contacts:write |
addListContacts | POST /api/v1/lists/{id}/contacts | contacts:write |
removeListContact | DELETE /api/v1/lists/{id}/contacts?email= (one list only; the contact is kept) | contacts:write |
listContacts / upsertContacts / updateContact / deleteContact | /api/v1/contacts | contacts:read / contacts:write |
listBusinesses({ projectId?, page?, perPage? }) / getBusiness(id, { projectId? }) | GET /api/v1/businesses, GET /api/v1/businesses/{id} | contacts:read |
createBusiness(input) / updateBusiness(id, input) / deleteBusiness(id, { projectId? }) — update replaces domains, merges attributes; delete keeps the contacts | POST /api/v1/businesses, PATCH / DELETE /api/v1/businesses/{id} | contacts:write |
upsertBusinessByExternalRef(system, ref, input) — idempotent; returns { business, created } | PUT /api/v1/businesses/by-ref/{system}/{ref} | contacts:write |
listBusinessMembers(id, { projectId?, page?, perPage? }) | GET /api/v1/businesses/{id}/members | contacts:read |
addBusinessMembers(id, members, { addedBy?, projectId? }) (max 100) / removeBusinessMembers(id, { contactIds?, emails?, projectId? }) — memberships only | POST / DELETE /api/v1/businesses/{id}/members | contacts:write |
addBusinessMembersByDomain(id, { projectId? }) — adds contacts at the business's domains | POST /api/v1/businesses/{id}/members/by-domain | contacts:write |
listSenders / createSender(input) — input.projectId? targets a project when the org has more than one | /api/v1/senders | senders:read / senders:write |
getSender / updateSender / deleteSender | /api/v1/senders/{id} | senders:read / senders:write |
listUseCases({ projectId?, includeArchived? }) / createUseCase(input) — input.projectId? as for createSender | /api/v1/use-cases | use-cases:read / use-cases:write |
getUseCase / updateUseCase / archiveUseCase / restoreUseCase — delete archives; restore undoes it | /api/v1/use-cases/{id} | use-cases:read / use-cases:write |
importSenders({ projectId? }) — adopts the project's provider-account identities; re-run to pick up provider verification | POST /api/v1/senders/import | senders:write |
listDomains / createDomain(domain, { projectId }) / getDomain / deleteDomain | /api/v1/domains | domains:read / domains:write |
getDomainDnsRecords / verifyDomain | /api/v1/domains/{id}/… | domains:read / domains:write |
getDomainBrandedTracking / enableDomainBrandedTracking | GET / POST /api/v1/domains/{id}/branded-tracking | domains:read / domains:write |
listInboxes / getInbox / updateInbox(id, { displayName?, forwardTo?, storeMessages?, openTracking?, status? }) — openTracking: true needs an active HTTPS branded host | /api/v1/inboxes, /api/v1/inboxes/{id} | inboxes:read / inboxes:write |
listSuppressions / addSuppressions | /api/v1/suppressions | org:read / org:write |
getProjectAddress(projectId) / setProjectAddress(projectId, { physicalAddress, addressComponents? }) | GET / PUT /api/v1/projects/{id}/address | org:read / org:write |
getInboxDbStatus | GET /api/v1/inbox-db/status | org:read |
connectInboxDb({ connectionString, provider? }) (202, starts a background connect) / testInboxDbConnection | POST /api/v1/inbox-db/connection / …/connection/test | org:write |
getInboxDbConnectAttempt (poll until status is not running) | GET /api/v1/inbox-db/connection | org:read |
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. - Businesses.
client.businessesgroups the ten business methods:list,get,create,update,delete,upsertByExternalRef,addByDomainandmembers.{list, add, remove}, each delegating to the flat method above. Tenancy:projectIdgoes in the query forget,deleteandaddByDomain, and in the body forupdate,createBusinessand the member writes. Omit it only when the org has one project. - Updates are
PATCH, and partial. Send only the fields you are changing. - Campaign reads take an optional
projectId. Omit it only when the org has one project; otherwise the server answers 400PROJECT_REQUIRED. A campaign in another project is a 404NOT_FOUND, same as a missing one. An org with no project gets 422NO_PROJECT; one with no marketing provider gets 404NO_MARKETING_PROVIDER.listCampaignsfilters after the provider's page, so a page can hold fewer thanperPagerows. The analytics route needsanalytics:read, notcampaigns:read. 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.projectIdnames the project the rows land in; omitted, they land in the use case's project, else in every project the key's owner can open. - Managed accounts cannot delete domains —
deleteDomainthrows403 MANAGED_ACCOUNT. - The inbox-db connection string is sent once and never echoed.
connectInboxDbstores it encrypted;InboxDbStatusomits it.schemaNameis the Postgres schema NevarMail owns in your database.
Anything still not covered — webhook endpoints, for one — goes through
request() (or requestPaginated() for list routes), which applies the same auth, envelope
unwrapping, throwing, and retries:
const endpoints = await nevarmail.request<unknown>("GET", "/api/v1/webhook-endpoints");
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.
Agent inboxes — NevarMailInbox
An agent inbox has its own API (/api/v1/inbox/*) and its own credential: a client id
(nvm_inbox_…) and secret from the inbox's Agents tab. API keys are refused there, and an
inbox credential reaches nothing outside its own inbox, so it is a separate client:
import { NevarMailInbox } from "@bernierllc/nevarmail";
const inbox = new NevarMailInbox({
clientId: process.env.NEVARMAIL_INBOX_CLIENT_ID!,
clientSecret: process.env.NEVARMAIL_INBOX_CLIENT_SECRET!,
// scopes: ["inbox:read"], // narrow the token; defaults to inbox:read + inbox:send
});
const { data } = await inbox.listEmails({ perPage: 10 });
for (const summary of data.filter((e) => !e.isRead)) {
const email = await inbox.getEmail(summary.id);
await inbox.send({
to: email.from,
subject: `Re: ${email.subject ?? ""}`,
text: "Got it.",
reply_to_email_id: email.id,
});
await inbox.markEmailRead(email.id);
}
| Method | Route | Scope |
|---|---|---|
listEmails({ page, perPage, threadId }) | GET /api/v1/inbox/emails | inbox:read |
getEmail(id) | GET /api/v1/inbox/emails/{id} | inbox:read |
markEmailRead(id) | POST /api/v1/inbox/emails/{id}/read | inbox:read |
deleteEmail(id) | DELETE /api/v1/inbox/emails/{id} | inbox:read |
send(input) | POST /api/v1/inbox/send | inbox:send |
rotateCredentials() | POST /api/v1/inbox/credentials/rotate | inbox:read |
- Tokens are handled for you. The client exchanges the credential for a short-lived
token (OAuth
client_credentialsat/api/oauth/token) on first use, renews it a minute before expiry, and on a401gets one fresh token and retries once. A rejected credential throwscode: "INVALID_CLIENT". sendis deliberately narrow. From is always the inbox's address; at most 5 recipients acrosstoandcc; no templates or lists. Hitting the daily limit is a429 inbox_daily_limit_reachedthat throws at once, withretryAfterset to the seconds until UTC midnight.rotateCredentials()returns the new secret once and switches the client to it. Store it. The old secret stops working.
Same options (baseUrl, timeoutMs, maxRetries, fetch) and the same error contract as
NevarMail.
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
});
Use-case groups and inherited settings
Create reusable project defaults with createUseCaseGroup, then create a use case with only its name and group ID. Groups allow suppressable, transactional, or mixed use cases; mixed groups have a default kind and separate tracking defaults per kind.
const group = await mail.createUseCaseGroup({
name: "Account emails", projectId,
allowedKind: "transactional", defaultSuppressable: false,
defaults: {
shared: { fromName: { value: "Accounts" } },
suppressable: {}, transactional: { clickTracking: { value: false } },
},
});
const login = await mail.createUseCase({ name: "Login", groupId: group.id });
await mail.updateUseCase(login.id, {
expectedRevision: login.settingsRevision,
settings: { set: { replyToEmail: null }, inherit: ["fromName"] },
});
An omitted override inherits; explicit false disables; explicit null clears a nullable field. settings on responses includes effective/configured values, source and override status. An override equal to the group value remains an override. Group defaults stay live for inheritors.
Use listUseCaseGroups, getUseCaseGroup, and deleteUseCaseGroup to manage groups. Delete returns {id, deleted: true} and refuses a group containing any members, including archived ones. To edit defaults, call previewUseCaseGroupUpdate(id, {changes}), review affected members, then updateUseCaseGroup with those changes, the preview's revision and token.
const target = { groupId: destinationId }; // null means Ungrouped
const preview = await mail.previewUseCaseGroupMove(login.id, target);
// Show preview.rows and both outcomes to the user before applying.
const idempotencyKey = globalThis.crypto.randomUUID();
const moved = await mail.moveUseCaseToGroup(login.id, {
...target, previewToken: preview.previewToken,
policy: "inherit_destination", // or "preserve_effective"
idempotencyKey,
});
Moves retain existing overrides. preserve_effective additionally snapshots inherited values as overrides. A kind change supplies suppressable through the same preview/apply flow, not an ordinary metadata PATCH. Tokens expire after 15 minutes and are bound to the actor/scope; use the same credential for preview and apply. A stale preview returns 409 and is not retried: fetch another comparison and obtain confirmation again.
The move key is a UUID in the request body. Omission generates one once per SDK call, preserved across automatic retries. Retain and reuse an explicit key when retrying a whole call after an ambiguous network result. A committed retry returns its original result even after token expiry; reusing the key for a different move is a conflict.
Production transitions require confirmation: {productionSending: true} and server permissions/readiness. Enabling tracking for transactional mail requires transactionalTracking: "transactional-tracking-v1" after the user sees the disclosure. Never auto-confirm these choices. Tracking is resolved server-side; SendEmailInput has no tracking overrides. Keep API credentials server-side: app browser components use session routes.