Official SDK

The SendHeron Node SDK

The official TypeScript SDK for the SendHeron email API. Typed send outcomes, automatic retries, and an idempotency key on every send by default. MIT licensed, zero dependencies.

npm install sendheron

Node 20 or newer, zero runtime dependencies, ESM and CommonJS with full type declarations, and MIT licensed. The source is public because a client library you cannot read is a client library you cannot trust with your production sending.

Your first send

import { SendHeron } from 'sendheron';

const sendheron = new SendHeron(process.env.SENDHERON_API_KEY);

const { data, error } = await sendheron.emails.sendTemplate({
  to: '[email protected]',
  templateId: '550e8400-e29b-41d4-a716-446655440000',
  variables: { orderId: '42' },
});

// A transport or request failure, after the SDK has already retried
// whatever was safe to retry.
if (error) throw error;

if (data.status === 'suppressed') {
  // We refused to send, and data.errorMessage says why in a stable
  // value such as 'HARD_SUPPRESSED'. Never retry this one.
  log.warn('send refused', { reason: data.errorMessage });
} else {
  // Accepted by the provider. Keep both ids: data.id reads the send
  // back later, providerMessageId ties it to provider logs.
  await orders.recordReceipt(data.id, data.providerMessageId);
}

The key comes from SENDHERON_API_KEY if you do not pass one. The base URL, the per-attempt timeout and the retry count are all constructor options, and every one of them has a working default.

What it does that a fetch wrapper does not

Retries are on, and they know what is safe to repeat
A 429 retries on every method, waiting exactly as long as the Retry-After header says, because a rate-limited request was never processed at all. A 5xx, a timeout or a network failure retries only when the request is replay-safe: a read, or an email send carrying an idempotency key. Two retries by default, and maxRetries: 0 turns the whole thing off, per client or per call.
Every send carries an idempotency key you never had to think about
The SDK mints one for each send and reuses it across its own internal retries, so a timeout cannot double-send even if you have never heard of the header. Pass your own to deduplicate across YOUR retries as well. A concurrent duplicate is retried until the server replays the original response rather than surfaced as a 409 you have to interpret.
An exhausted retry hands you the key back
When the SDK gives up, the key it was using is on error.idempotencyKey. Resume the same logical send with that key instead of minting a new one, and the send that may already have happened cannot happen twice. This is the field that makes a job queue safe.
The send outcome is a type, not a convention
data.status is sent or suppressed, so the compiler makes you handle the case where we refused. The suppression reasons are exported as SEND_BLOCK_REASONS, which means the ten values are a list you can exhaustively switch over rather than strings you copy off this page.
Nothing throws
Every call resolves to { data, error }. The only thing that throws is constructing the client with no API key at all, which is a programming mistake rather than a runtime outcome. There is no try/catch to forget around a send.

Idempotency, when it is your business rule

The SDK handles retry safety on its own. The one thing it cannot guess is whether two calls from your code are the same logical send, which is what your own key is for.

// The SDK generates a key for every send and reuses it across its own
// retries. Pass your own when the DEDUPLICATION is a business fact:
// this receipt should exist once per order, no matter how many times
// your job runner wakes up.
const { data, error } = await sendheron.emails.sendTemplate(
  { to: customer.email, templateId: RECEIPT_TEMPLATE, variables },
  { idempotencyKey: `receipt-${order.id}` },
);

// If the SDK exhausts its retries, resume the SAME logical send with
// the key it used rather than minting a fresh one, which could
// double-send.
if (error) await queue.retryLater({ idempotencyKey: error.idempotencyKey });

The outcome you have to handle

A successful call can still mean we did not send. When data.status is suppressed, data.errorMessage carries one of 10 stable values, exported from the package as SEND_BLOCK_REASONS so you can switch over them exhaustively instead of copying strings off a documentation page. None of them are retryable. Every reason and what it means is on the reference index.

What it covers today

emails
send, sendTemplate, sendBulk, get, cancelScheduled
The whole transactional surface, including reading a send back by id.
templates
list, get, create, update, remove, preview, validate
create requires emailType, and update requires confirmEmailTypeChange to reclassify one.
suppressions
list, check, add, remove
check returns the per-stream verdict, from the same gate the send paths run.
usage
get
The pool position and the rate ceilings. Watch monthlySends.transactionalRemaining.

Not wrapped yet: contacts, tags, sequences, sending domains and sender identities and analytics. They arrive in later minor releases. Until then each one is a documented HTTP call rather than a gap you have to reverse engineer, and the two paths mix freely in the same codebase: the SDK is a client for the same API, not a separate product with its own semantics.

The repository keeps a snapshot of the API contract and its CI re-fetches the live document on a schedule, failing the build when the two differ. An API change becomes a failing build in the SDK rather than a surprise in your integration.

Private beta

The SDK needs a key, and keys come with the beta

Join the list and your onboarding email carries a scoped key and the base URL, so the quickstart above runs on the first try.