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 sendheronNode 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 if (data.status === 'queued') {
// Waiting for daily sending capacity or new-sender warm-up. It goes
// out by itself and turns 'sent' on this same record, so keep
// data.id and do not send it again.
await orders.recordPendingReceipt(data.id);
} 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 typed, so the compiler knows the two outcomes that are not a finished send: suppressed, where we refused, and queued, a send waiting for daily sending capacity that goes out by itself. The suppression reasons are exported as SEND_BLOCK_REASONS, so you switch over a list rather than strings you copy off this page. Keep a default branch anyway: a new reason can reach the API before the version of the SDK you have installed knows its name.
- The error says whether trying again can work
- error.retryable is true for a 429, a 5xx, a timeout or a network failure, and false for a 4xx, which is a request to fix, and for a call you aborted yourself. A job runner should fail the job for good when it is false, rather than spend attempts on a request that will fail the same way every time.
- 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}` },
);
// When the SDK has exhausted its own retries, error.retryable says
// whether trying again later can work: true for a 429, a 5xx, a
// timeout or a network failure, false for a 4xx, which fails the same
// way every time. Resume a retryable send with the SAME key rather
// than minting a fresh one, which could double-send.
if (error?.retryable) {
await queue.retryLater({ idempotencyKey: error.idempotencyKey });
} else if (error) {
throw error;
}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 11 stable values. The package exports them as SEND_BLOCK_REASONS so you can switch over them instead of copying strings off a documentation page; keep a default branch, because a new reason can reach the API before the version you have installed knows it. None of them are retryable. Every reason and what it means is on the reference index.
It can also mean we have not sent yet. When data.status is queued, the send is waiting for daily sending capacity or new-sender warm-up. It goes out by itself and turns sent on the same record, so keep data.id, read it back with emails.get() when you need to know, and do not send it again. How daily sending works is on the reference index too.
Whose problem a refusal is
Every reason belongs to one of two groups, and the package exports both. SENDER_SIDE_BLOCK_REASONS are your own account's configuration or state, such as an unverified From domain, a workspace with no sender identity, paused sending, or an account still waiting for its sending review. Alert on those and fix the setup, and keep the address, which did nothing wrong. RECIPIENT_BLOCK_REASONS are facts about the address, such as a bounce, a spam complaint or an opt-out: surface them and never retry them. isSenderSideBlock(reason) tells the two apart, and treats a reason it does not know as recipient-side, the direction that never retries, so a new grouping reaches you with a package update.
import { SendHeron, isSenderSideBlock } 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' },
});
if (error) throw error;
if (data.status === 'suppressed') {
if (isSenderSideBlock(data.errorMessage)) {
// Our own account, not the recipient: alert, fix the setup, keep
// the address. SENDER_UNDER_REVIEW lands here until the
// organization is approved as a sender.
const { data: usage } = await sendheron.usage.get();
console.warn('send refused on our side', {
reason: data.errorMessage,
sendingReview: usage?.plan.sendingReview,
});
} else {
// A fact about the address: a bounce, a complaint, an opt-out, or
// a reason this version of the package does not know yet.
// Surface it and never retry it.
console.info('this address will not get the mail', {
to: data.to,
reason: data.errorMessage,
});
}
}SENDER_UNDER_REVIEW is the sender-side reason a new account meets first. Until a person at SendHeron approves the organization as a sender, it reaches only team members who have confirmed their email address and addresses on its own verified domains, and every other send is suppressed with this reason, transactional mail included. Retrying does not help: read plan.sendingReview from usage.get(), which is SANDBOX, REQUESTED or APPROVED, and have the account owner ask for the review from the getting-started card in the dashboard. A send refused before approval is not replayed afterwards. Send it again, with a new idempotency key if you pass your own, because a reused key replays the recorded refusal.
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 for the recipient and your organization, from the gate the send paths run; sender configuration is only checked on a real send. A sender-side verdict such as SENDER_UNDER_REVIEW is about your organization, not the address, so do not drop the address over it. - usage
- get
The plan's state, the pool position, the contact cap and the rate ceilings. Watch plan.trialEndsAt, plan.lapsedReason, plan.sendingReview and 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.
Free trial
The SDK needs a key, and keys come with an account
Start a trial, then run the quickstart above against your own workspace.
7-day free trial. Pick a plan, add a card, cancel before the trial ends and you won't be charged. 1,000 contacts and 5,000 sends during the trial.