Node SDK
@avelto/sdk is the official Node SDK. Node 20 or newer, ESM and CommonJS, fully typed, no runtime dependencies.
npm install @avelto/sdkSend
import { Avelto } from "@avelto/sdk";
const avelto = new Avelto(process.env.AVELTO_API_KEY);
const { id } = await avelto.emails.send({
from: "Acme <[email protected]>",
to: "[email protected]",
subject: "Your receipt",
html: "<p>Thanks for your order.</p>",
});to, cc and bcc take a single address or an array. Addresses can be [email protected] or Name <[email protected]>. Provide html, text or both. You can also pass reply_to, headers, tags (up to 10) and attachments (base64 content or an http(s) url we fetch, up to 10 files and 7 MB in total). Set unsubscribe_url on bulk mail to add the one-click List-Unsubscribe headers. To send a stored template instead of a body, pass template_id or template_slug with variables and leave subject, html and text unset (see Templates).
Options
const avelto = new Avelto(apiKey, {
baseUrl: "https://api.avelto.dev", // also read from AVELTO_BASE_URL
timeoutMs: 30_000, // per request
fetch: customFetch, // any fetch-compatible function
retry: { maxAttempts: 3, baseDelayMs: 300, maxDelayMs: 5_000 }, // the defaults
});Idempotency
Every emails.send call carries an Idempotency-Key header. By default it is a fresh random UUID per call, which is what makes the SDK's own retries (below) safe: a retried request can only ever create the one email. Pass your own key, such as an order id, to make retries at your level safe too. A replay returns the id of the email that was already created, with status 200, and does not send again.
const { id } = await avelto.emails.send(
{
from: "[email protected]",
to: "[email protected]",
subject: "Receipt #1042",
text: "...",
},
{ idempotencyKey: "receipt-1042" },
);Retries
The SDK makes up to three attempts at a failed request (the call plus two retries) with exponential backoff and jitter (300 ms, then 600 ms, capped at 5 s, each varied by up to half). On 429 it waits for the Retry-After the server sends instead.
What is retried depends on whether the request could have been processed:
| Failure | GET, DELETE, emails.send, emails.sendBatch | Every other POST and PATCH (create domain, webhook, suppression, template; update template; cancel; retry delivery) |
|---|---|---|
429, 502, 503 (the server did not process the request) | retried | retried |
Network error, timeout, 504 (unknown whether it was processed) | retried | not retried |
emails.send and emails.sendBatch sit in the first column because they carry an idempotency key. The others are not repeated after a network error, because a second domains.create could succeed twice, and a repeated templates.update would save a duplicate version. Tune it with the retry option or set maxAttempts: 1 to turn it off.
// On by default: 3 attempts, exponential backoff with jitter.
const avelto = new Avelto(apiKey, { retry: { maxAttempts: 5 } });
// Off, if you run your own retry loop:
const once = new Avelto(apiKey, { retry: { maxAttempts: 1 } });
// After the last attempt the error is thrown as usual:
try {
await avelto.emails.send({ from, to, subject, text });
} catch (err) {
if (err instanceof AveltoError && err.status === 429) {
console.log("rate limited; the server asked for " + err.retryAfterSeconds + "s");
}
}This is also what keeps deploys invisible to you: while an API instance restarts, a request that reaches it is refused before it is read, and the retry lands on the other instance.
Scheduled send and cancel
scheduled_at is an ISO 8601 timestamp, in the future and at most 30 days ahead. A scheduled email can be cancelled until it is sent.
const { id } = await avelto.emails.send({
from: "[email protected]",
to: "[email protected]",
subject: "Your trial ends tomorrow",
text: "...",
scheduled_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
});
const { status } = await avelto.emails.cancel(id); // status === "cancelled"Cancelling an email that is no longer scheduled throws an AveltoError with code not_scheduled.
Batch send
emails.sendBatch sends up to 100 messages in one call. Each message is validated, limited and queued exactly as a single send is, and each is accepted or refused on its own, so read results rather than assuming the call either worked or did not. results[i] lines up with messages[i]. The call carries an Idempotency-Key like emails.send, so a retry after a timeout returns the same ids instead of sending twice.
const { results, accepted, failed } = await avelto.emails.sendBatch(
{
messages: [
{ from: "[email protected]", to: "[email protected]", subject: "Receipt #1042", text: "..." },
{ from: "[email protected]", to: "[email protected]", subject: "Receipt #1043", text: "..." },
],
},
{ idempotencyKey: "receipts-2026-09-17" },
);
// results[i] lines up with messages[i]; each is accepted or refused on its own.
for (const r of results) {
if (r.ok) console.log(r.index, r.id);
else console.error(r.index, r.error.code, r.error.message);
}
console.log(accepted, failed);Fetch and list
const email = await avelto.emails.get(id);
console.log(email.status); // "queued" | "scheduled" | "sent" | "delivered" | "bounced" | "complained" | "failed" | "cancelled"
console.log(email.events.map((e) => e.type)); // ["email.queued", "email.sent", "email.delivered"]Lists are newest first and cursor-paginated. Filter by status, tag, mode and q, a substring search over recipient, subject and message id (or an exact email id). mode: a live key defaults to live and may ask for test; a test key only ever sees test emails.
let cursor: string | null = null;
do {
const page = await avelto.emails.list({
status: "bounced",
tag: "onboarding",
limit: 100,
cursor: cursor ?? undefined,
});
for (const e of page.data) console.log(e.id, e.to, e.subject);
cursor = page.next_cursor;
} while (cursor);Domains
Add a domain, publish the DNS records it returns, then poll get until it is verified. domains.get re-checks verification on every call until the domain is verified.
const domain = await avelto.domains.create({ name: "mail.acme.com" });
for (const r of domain.dns_records) {
console.log(`${r.type}\t${r.name}\t${r.value}\t(${r.purpose})`);
}
// Publish the records, then poll. domains.get re-checks verification on every call until the domain is verified.
let status = domain.status;
while (status === "pending") {
await new Promise((r) => setTimeout(r, 30_000));
status = (await avelto.domains.get(domain.id)).status;
}
console.log(status); // "verified" (or "failed")const { data } = await avelto.domains.list();
await avelto.domains.delete(domain.id);Templates
A template is a stored subject and body with {{variables}} in them. Create one, then send it by passing template_id or template_slug and variables instead of a subject and body. A variable the template uses and you do not supply is an error, not an empty string. Every update saves a version; versions lists them newest first, and restore writes an old one forward as a new version, so the history stays append-only. See Templates.
const template = await avelto.templates.create({
name: "Welcome",
subject: "Welcome to Acme, {{name}}",
html: "<p>Hi {{name}}, thanks for signing up.</p>",
});
console.log(template.slug, template.variables); // "welcome" ["name"]
// Send it: the template supplies the subject and body.
const { id } = await avelto.emails.send({
from: "[email protected]",
to: "[email protected]",
template_slug: "welcome",
variables: { name: "Jane" },
});
// Every save keeps a version; restore writes an old one forward as a new one.
await avelto.templates.update(template.id, { subject: "Welcome aboard, {{name}}" });
const { data: versions } = await avelto.templates.versions(template.id);
await avelto.templates.restore(template.id, versions[1].version);
const { data } = await avelto.templates.list();
await avelto.templates.get(template.id);
await avelto.templates.delete(template.id);Webhooks
Create an endpoint. The signing secret is returned once, on creation. Store it.
const endpoint = await avelto.webhooks.create({
url: "https://acme.com/hooks/avelto",
// optional; defaults to all eight event types
events: ["email.delivered", "email.bounced", "email.complained"],
});
console.log(endpoint.secret);Every delivery is a JSON POST with an Avelto-Signature header of the form t=<unix seconds>,v1=<hex>. Verify it against the raw body with verifyWebhookSignature before you trust the payload, and use the event id to de-duplicate. Signatures older than five minutes are rejected; pass { toleranceSeconds } to change that.
import express from "express";
import { verifyWebhookSignature, type WebhookPayload } from "@avelto/sdk";
const app = express();
const secret = process.env.AVELTO_WEBHOOK_SECRET;
app.post("/hooks/avelto", express.raw({ type: "application/json" }), async (req, res) => {
const body = req.body.toString("utf8");
const signature = req.header("Avelto-Signature");
const ok = await verifyWebhookSignature(secret, body, signature);
if (!ok) return res.status(400).send("invalid signature");
const event = JSON.parse(body) as WebhookPayload;
if (event.type === "email.bounced") {
console.log("bounced", event.data.email_id, event.data.details);
}
res.sendStatus(200);
});Deliveries
const { data: endpoints } = await avelto.webhooks.list();
const endpoint = await avelto.webhooks.get(endpointId);
const { data: deliveries, next_cursor } = await avelto.webhooks.listDeliveries(endpointId, { limit: 50 });
const failed = deliveries.find((d) => d.status === "failed");
if (failed) await avelto.webhooks.retryDelivery(endpointId, failed.id);
await avelto.webhooks.delete(endpointId);Suppressions
Addresses that hard-bounce or complain are suppressed automatically and sends to them are rejected. You can manage the list yourself.
const { data, next_cursor } = await avelto.suppressions.list({ limit: 100 });
data.forEach((s) => console.log(s.email_address, s.reason)); // "bounce" | "complaint" | "manual"
await avelto.suppressions.create({ email_address: "[email protected]" });
await avelto.suppressions.delete("[email protected]");Account
account.get() returns the account as an integrator sees it: the key's mode and scopes, the sandbox domain with every recipient a sandbox send may reach, and the domains, webhook endpoints and templates that exist. Any scope can read it. Read it first when wiring up a new project; it says what is set up and what is not.
const summary = await avelto.account.get();
console.log(summary.key.mode, summary.key.scopes); // "test" ["emails:send", ...]
console.log(summary.sandbox.domain, summary.sandbox.recipients);
console.log(summary.domains, summary.webhooks, summary.templates);Errors
Every failed request throws AveltoError with the HTTP status, the API code, the message and optional details, plus requestId (the x-request-id the API answered with, to quote when you write in) and, on a 429, retryAfterSeconds from the Retry-After header. A request that gets no response at all (DNS, TLS, timeout) has status 0 and code network_error, with the underlying error in err.cause; the isNetworkError getter is true for exactly those.
import { AveltoError } from "@avelto/sdk";
try {
await avelto.emails.send({
from: "[email protected]",
to: "[email protected]",
subject: "Hi",
text: "Hi",
});
} catch (err) {
if (err instanceof AveltoError) {
console.error(err.status, err.code, err.message, err.details);
} else {
throw err;
}
}The codes and their meanings are listed in the API reference.
Test mode
Keys starting with av_test_ never send real email. They run the full pipeline and emit the same events and webhooks, so you can build against them without touching an inbox. The simulator recipients delivered@sandbox.avelto.dev, bounced@sandbox.avelto.dev and complained@sandbox.avelto.dev work in both modes. See Test mode and sandbox.
Types
All request and response types are exported:
import type { SendEmailRequest, Email, EmailSummary, Domain, DnsRecord, WebhookEndpoint, WebhookPayload, Suppression, ErrorCode } from "@avelto/sdk";