npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 in bernierllc/email_demo. Any change to the NevarMail v1 API surface requires a matching change here, in the same unit of work — see that repo's CLAUDE.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

Requires 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 (api vs mcp) — this package speaks REST, so it needs an api key. Pass an mcp key and the constructor throws immediately rather than letting the server answer a 403 KEY_TYPE_MISMATCH at your first send, which reads like an auth bug.
  • Mode (live vs test) — a test key sends only to addresses on the org's test allow-list; anything else is a 422 TEST_MODE_RECIPIENT_NOT_ALLOWED. The mode is a property of the key, never a client option: client.mode reports 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. listTemplates and 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 a variables key.
  • deleteTemplate never 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" requires use_case_id, and use_case_id with scope: "account" is a 400.
  • Managed accounts cannot delete domainsdeleteDomain throws 403 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
});