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

pulsenote

v1.5.0

Published

Official TypeScript/Node SDK for the Pulsenote email API.

Readme

pulsenote-node

Official TypeScript/Node SDK for the Pulsenote email API. Published to npm as pulsenote.

  • Zero runtime dependencies — built on the platform fetch
  • ESM and CommonJS, with types for both
  • Typed error hierarchy, automatic retries with backoff, per-request timeouts and AbortSignal
  • Lazy pagination over notification history
  • Types derived from the OpenAPI spec, so they cannot drift from the API

Install

npm install pulsenote

Requires Node 22 or newer. Node 20 reached end-of-life in April 2026 and is not tested.

Quick start

import { Pulsenote } from 'pulsenote';

const pulsenote = new Pulsenote({ apiKey: process.env.PULSENOTE_API_KEY });

const { id, status } = await pulsenote.notifications.send({
  to: '[email protected]',
  from: '[email protected]',
  subject: 'Welcome',
  html: '<h1>Hi</h1>',
});

console.log(id, status); // "<uuid>" "QUEUED"  (or "SANDBOX" — see below)

apiKey falls back to PULSENOTE_API_KEY and baseUrl to PULSENOTE_BASE_URL, so new Pulsenote() works when both are in the environment.

Sending is asynchronous. send resolves once the API has accepted the message (HTTP 202), so a live send comes back QUEUED. Read the record back with notifications.retrieve(id) to see whether it was DELIVERED, FAILED or BOUNCED.

Sandbox — your first send probably won't be delivered

Pulsenote only sends from your own verified domain; there is no shared sending address. Until you have verified one, sends are accepted and fully rendered but never delivered, and come back as sandbox instead of failing:

const result = await pulsenote.notifications.send({ /* … */ });

if (result.sandbox) {
  // status === 'SANDBOX' — rendered, stored for preview, not delivered.
  console.warn(result.message);
}

This exists so you can wire up the integration before pointing production DNS at an email vendor. The from you pass is echoed back untouched, so going live is just verifying a domain — no code changes. Sandbox is capped at 50 messages/month and does not consume your plan allowance.

Verify a domain with pulsenote.domains, or in Settings → Domains. A subdomain such as notify.yourcompany.com is recommended: its DNS records are separate from your main domain, so publishing them cannot affect the deliverability of your existing company email.

Guard against shipping in sandbox by asserting on it in your integration tests: expect(result.sandbox).toBeUndefined().

Nodemailer transport

Everything already written against Nodemailer keeps working — including the mail layers of frameworks built on top of it. One line changes:

npm install pulsenote nodemailer
import nodemailer from 'nodemailer';
import { pulsenoteTransport } from 'pulsenote/nodemailer';

const transport = nodemailer.createTransport(pulsenoteTransport());

await transport.sendMail({
  from: 'Acme <[email protected]>',
  to: '[email protected]',
  subject: 'Welcome',
  html: '<h1>Hi</h1>',
});

pulsenoteTransport() takes the same options as new Pulsenote() — including the PULSENOTE_API_KEY fallback — or { client } to reuse one you already built.

Copies and attachments

cc, bcc, replyTo and attachments all go through:

await transport.sendMail({
  from: '[email protected]',
  to: '[email protected]',
  cc: '[email protected]',
  replyTo: '[email protected]',
  subject: 'Your invoice',
  html: '<p>Attached.</p>',
  attachments: [
    { filename: 'invoice.pdf', content: await readFile('invoice.pdf'), contentType: 'application/pdf' },
  ],
});

Attachment content may be a Buffer or a string; pass encoding: 'base64' when the string is already encoded. Inline images work through cid, as they do everywhere else in Nodemailer.

One limitation worth knowing: attachments given as path or href are refused. Nodemailer resolves those inside its own transports, so the bytes never reach this one — and sending the mail without the file would be worse than failing. Read the file yourself and pass content.

Limits: 20 attachments and 10 MB per message (decoded), and 50 recipients across to, cc and bcc.

Several recipients

Pulsenote models one recipient per message, so to: ['[email protected]', '[email protected]'] is fanned out through the batch endpoint — one message each, up to MAX_BATCH_SIZE. Recipients therefore do not see one another in the To header. For transactional mail that is usually what you want; it is a behaviour change if you were relying on a shared To.

That fan-out decides how copies travel: cc and bcc ride on the first message only, so a cc'd address receives one copy rather than one per recipient. replyTo and attachments go on every message.

CMS platforms

Payload and Strapi both send through Nodemailer, so they need no Pulsenote-specific plugin — the transport above plugs straight in.

Payload

import nodemailer from 'nodemailer';
import { nodemailerAdapter } from '@payloadcms/email-nodemailer';
import { pulsenoteTransport } from 'pulsenote/nodemailer';

export default buildConfig({
  email: nodemailerAdapter({
    transport: nodemailer.createTransport(pulsenoteTransport()),
    defaultFromAddress: '[email protected]',
    defaultFromName: 'Your Company',
  }),
});

Payload verifies the transport on boot. pulsenoteTransport() implements verify() against the API, so a wrong or revoked key fails at boot rather than at the first send — and you do not need skipVerify.

Strapi

// config/plugins.js
const { pulsenoteTransport } = require('pulsenote/nodemailer');

module.exports = () => ({
  email: {
    config: {
      provider: 'nodemailer',
      providerOptions: pulsenoteTransport(),
      settings: { defaultFrom: '[email protected]' },
    },
  },
});

settings.defaultReplyTo works as expected — Strapi attaches it to every message and the transport forwards it.

Auth.js / NextAuth provider

Magic links and password resets are the core of what this API is for, so this is the shortest path from evaluating Pulsenote to being signed in:

import NextAuth from 'next-auth';
import { PulsenoteProvider } from 'pulsenote/auth';

export const { handlers, signIn, auth } = NextAuth({
  providers: [PulsenoteProvider({ from: '[email protected]' })],
});

Modelled on the HTTP-based providers Auth.js ships (Resend, Postmark, SendGrid) rather than the Nodemailer one — no SMTP, no extra dependency. PulsenoteProvider() takes the same options as new Pulsenote(), including the PULSENOTE_API_KEY fallback, or { client } to reuse one you already built.

It fails loudly when a link would not arrive

If your account has no verified sending domain, the message is rendered but never delivered. Auth.js has no way to know that: it would report success and the user would sit on "check your email" forever, with nothing in any log to explain it.

So the provider throws instead:

Pulsenote: the sign-in link was rendered but NOT delivered, because your account has
no verified sending domain. Verify one in Settings — no code changes are needed — or
the user will wait for an email that never arrives.

Customising the email

A reasonable default template ships with the provider. Override any part of it:

PulsenoteProvider({
  from: '[email protected]',
  subject: ({ host }) => `Your ${host} sign-in link`,
  html: ({ url, email }) => renderMyTemplate({ url, email }),
  text: ({ url }) => `Sign in: ${url}`,
});

text is worth setting alongside html — it is what spam filters read.

Prefer to go through Nodemailer? pulsenote/nodemailer works with Auth.js's Nodemailer provider instead.

Resources

pulsenote.notifications

| Method | Endpoint | |---|---| | send(params) | POST /api/v1/notifications/send | | sendBatch(messages) | POST /api/v1/notifications/batch | | retrieve(id) | GET /api/v1/notifications/{id} | | list({ page, limit, status, search }) | GET /api/v1/notifications | | iterate({ ... }) | lazy AsyncGenerator over every page | | listAll({ ... }) | every page collected into an array | | stats() | GET /api/v1/notifications/stats |

Exactly one content source must be supplied to sendhtml, text, templateId or templateSlug. The type system enforces it:

await pulsenote.notifications.send({
  to: '[email protected]',
  templateSlug: 'welcome',
  locale: 'pl',
  templateData: { name: 'Greg', plan: 'Pro' },
});
for await (const n of pulsenote.notifications.iterate({ status: 'BOUNCED' })) {
  console.log(n.recipient, n.failureReason);
}

list and iterate also accept search, which matches recipient or subject case-insensitively.

Building the payload dynamically and cannot satisfy the union? Cast through the looser SendEmailPayload type: send(payload as SendEmailParams).

Batch sending

sendBatch queues up to 500 messages (MAX_BATCH_SIZE) in one request. Each message is validated independently, so the batch is partial-success: one bad recipient rejects that message and the rest still go out.

const batch = await pulsenote.notifications.sendBatch([
  { to: '[email protected]', subject: 'Welcome', html: '<b>Hi</b>' },
  { to: '[email protected]', templateSlug: 'welcome', locale: 'pl', templateData: { name: 'Greg' } },
]);

console.log(`${batch.queued}/${batch.total} queued`);

for (const result of batch.results) {
  // `status` discriminates the union — `error` and `id` narrow accordingly.
  if (result.status === 'rejected') console.error(result.index, result.error);
}

A partly-failed batch still returns 202 and does not throw — check batch.rejected rather than assuming success. The promise only rejects for whole-request failures: bad key, quota exhausted, or a batch that is empty or over MAX_BATCH_SIZE.

pulsenote.templates

| Method | Endpoint | |---|---| | list({ locale }) | GET /api/v1/templates | | retrieve(id) | GET /api/v1/templates/{id} | | listLocales(slug) | GET /api/v1/templates/slug/{slug}/locales | | create(params) | POST /api/v1/templates | | update(id, params) | PUT /api/v1/templates/{id} | | delete(id) | DELETE /api/v1/templates/{id} | | render(id, { data }) | POST /api/v1/templates/{id}/render | | export() | GET /api/v1/templates/export | | import(params) | POST /api/v1/templates/import |

slug is unique per tenant and locale, so reusing a slug with a different locale creates a translation rather than a conflict.

Moving templates between accounts

const file = await source.templates.export();
const result = await target.templates.import(file);
// { created: 3, updated: 0, skipped: 0, results: [...] }

Identity in the file is slug + locale, not id, so importing the same export twice does nothing the second time. A template that already exists is skipped unless you ask otherwise:

await target.templates.import({ ...file, onConflict: 'overwrite' });

Your plan's template limit applies to the import as a whole, counting distinct slugs — locale variants of one template do not consume extra quota.

pulsenote.domains

| Method | Endpoint | |---|---| | list() | GET /api/v1/domains | | add(params) | POST /api/v1/domains | | dnsRecords(id) | GET /api/v1/domains/{id}/dns-records | | zoneFile(id) | GET /api/v1/domains/{id}/zone-file (plain text) | | verify(id) | POST /api/v1/domains/{id}/verify | | delete(id) | DELETE /api/v1/domains/{id} |

You can only send from a VERIFIED domain, so the flow is add → publish the returned DNS records → verify. See examples/verify-domain.ts.

Errors

Every failure rejects with a PulsenoteError subclass:

| Class | Status | Typical cause | |---|---|---| | BadRequestError | 400 | validation failed; .validationErrors lists each rule | | AuthenticationError | 401 | missing, unknown or revoked API key | | PermissionDeniedError | 403 | from is outside your verified domains, or a quota is exhausted | | NotFoundError | 404 | no such notification / template / domain | | ConflictError | 409 | domain already registered | | UnprocessableEntityError | 422 | semantically invalid request | | RateLimitError | 429 | .retryAfter (seconds) and .rateLimit quota | | ServerError | 5xx | the API failed to process the request | | ConnectionError | — | the request never reached the API | | TimeoutError | — | subclass of ConnectionError |

import { PulsenoteError, RateLimitError } from 'pulsenote';

try {
  await pulsenote.notifications.send({ to, subject, html });
} catch (error) {
  if (error instanceof RateLimitError) {
    console.log(error.retryAfter, error.rateLimit.remainingPerMinute);
  } else if (error instanceof PulsenoteError) {
    console.error(error.status, error.message, error.body);
  } else {
    throw error;
  }
}

The API validates with forbidNonWhitelisted, so an unknown property is rejected as hard as a missing one. undefined values are dropped before the request is sent, so { locale: undefined } is safe.

Retries

The client retries after connection failures, timeouts, 408, 429 and 5xx, with exponential backoff and jitter (maxRetries: 2 by default).

POST is treated as unsafe: the API has no idempotency keys, so replaying notifications.send would deliver the email twice. A POST is therefore only retried on 429, where the rate-limit guard rejected the request before it did any work. The two read-only POST endpoints — templates.render and domains.verify — opt back in internally.

Retry-After is honoured up to maxRetryAfter (30s). Beyond that the RateLimitError is thrown so your own scheduler can decide what to do.

Configuration

const pulsenote = new Pulsenote({
  apiKey: process.env.PULSENOTE_API_KEY,
  baseUrl: 'https://api.pulsenote.eu', // default
  timeout: 30_000,                     // ms, 0 disables
  maxRetries: 2,
  initialRetryDelay: 500,              // ms, doubled per attempt
  maxRetryDelay: 8_000,                // ms
  maxRetryAfter: 30_000,               // ms — longer waits are handed back to you
  headers: { 'X-Tenant': 'acme' },
  userAgentSuffix: 'acme-billing/2.1',
  fetch: myInstrumentedFetch,
  logger: { warn: (msg, meta) => log.warn(msg, meta) },
});

Every resource method takes per-call overrides as its last argument:

const controller = new AbortController();
setTimeout(() => controller.abort(), 1_000);

await pulsenote.templates.list(
  { locale: 'pl' },
  { signal: controller.signal, timeout: 5_000, maxRetries: 0 },
);

Aborted requests are never retried and reject with the original AbortError.

Need an endpoint the resources do not cover yet?

const { data, status, headers, rateLimit } = await pulsenote.rawRequest({
  method: 'GET',
  path: '/api/v1/something-new',
});

Scope

The SDK covers the data plane — the endpoints authenticated with your X-API-Key (notifications, templates, domains). Account management (auth, team, billing, GDPR) uses JWT auth and belongs to the dashboard, not to customer integrations, so it is deliberately out of scope.

How generation works

api-gateway (NestJS decorators)   source of truth
        │  npm run spec:export
        ▼
openapi/pulsenote-public-api.json (in Pulsenote/pulsenote)
        │  npm run generate  ── fetches the spec verbatim
        ▼
openapi/pulsenote-public-api.json (here) ──► src/generated/schema.d.ts
        │                                          │  hand-written resources
        └──────────────────────────────────────────┴──► npm publish

Only src/generated/ is machine-written. The transport, resources, errors and types are hand-written on top of the generated schema, which is what keeps the ergonomics under our control while the shapes stay tied to the API.

test/spec-coverage.test.ts is the drift guard: it asserts that every operation in the spec is reachable through a resource method and that each method hits exactly the path and verb the spec declares. A new endpoint upstream fails the build until it is wired up.

npm run generate                                    # from https://pulsenote.eu/openapi.json
SPEC_URL=https://other.host/openapi.json npm run generate
SKIP_SPEC_FETCH=1 npm run generate                  # regenerate types only

The default spec URL is the copy the landing site publishes — Pulsenote/pulsenote is private, so raw.githubusercontent.com 404s without a token. api.pulsenote.eu/api-json serves the full internal spec (43 paths incl. JWT endpoints), not this one.

.github/workflows/sdk_generation.yaml runs this weekly and opens a PR with the diff. .github/workflows/sdk_publish.yaml builds and publishes when a GitHub release is cut.

Required secrets

Set at the org level (Pulsenote) so every pulsenote-* SDK repo inherits it, or per-repo via Terraform (actions_secrets on the github-repository module):

| Secret | Purpose | |---|---| | NPM_TOKEN | Publishing the pulsenote package |

GITHUB_TOKEN is provided automatically by Actions.

Development

npm ci
npm test           # vitest, no network
npm run typecheck
npm run build      # tsup → dist/ (ESM + CJS + .d.ts)

See CONTRIBUTING.md for the generated-vs-hand-written split and CHANGELOG.md for release notes.

License

MIT © GP IT-Tech