pulsenote
v1.5.0
Published
Official TypeScript/Node SDK for the Pulsenote email API.
Maintainers
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 pulsenoteRequires 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.
sendresolves once the API has accepted the message (HTTP 202), so a live send comes backQUEUED. Read the record back withnotifications.retrieve(id)to see whether it wasDELIVERED,FAILEDorBOUNCED.
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 nodemailerimport 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/nodemailerworks with Auth.js'sNodemailerprovider 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 send — html, 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
202and does not throw — checkbatch.rejectedrather than assuming success. The promise only rejects for whole-request failures: bad key, quota exhausted, or a batch that is empty or overMAX_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 publishOnly 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 onlyThe 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
