# Node SDK

`@avelto/sdk` is the official Node SDK. Node 20 or newer, ESM and CommonJS, fully typed, no runtime dependencies.

**npm**

```bash
npm install @avelto/sdk
```

## Send

```ts
import { Avelto } from "@avelto/sdk";

const avelto = new Avelto(process.env.AVELTO_API_KEY);

const { id } = await avelto.emails.send({
  from: "Acme <hello@mail.acme.com>",
  to: "jane@example.com",
  subject: "Your receipt",
  html: "<p>Thanks for your order.</p>",
});
```

`to`, `cc` and `bcc` take a single address or an array. Addresses can be `a@b.com` or `Name <a@b.com>`. 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](#templates)).

### Options

```ts
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.

```ts
const { id } = await avelto.emails.send(
  {
    from: "billing@mail.acme.com",
    to: "jane@example.com",
    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.

```ts
// 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.

```ts
const { id } = await avelto.emails.send({
  from: "hello@mail.acme.com",
  to: "jane@example.com",
  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.

```ts
const { results, accepted, failed } = await avelto.emails.sendBatch(
  {
    messages: [
      { from: "billing@mail.acme.com", to: "jane@example.com", subject: "Receipt #1042", text: "..." },
      { from: "billing@mail.acme.com", to: "sam@example.com", 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

```ts
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.

```ts
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.

```ts
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")
```

```ts
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](/docs/templates).

```ts
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: "hello@mail.acme.com",
  to: "jane@example.com",
  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.

```ts
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.

```ts
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

```ts
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.

```ts
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: "unsubscribed@example.com" });
await avelto.suppressions.delete("unsubscribed@example.com");
```

## 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.

```ts
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.

```ts
import { AveltoError } from "@avelto/sdk";

try {
  await avelto.emails.send({
    from: "hello@mail.acme.com",
    to: "jane@example.com",
    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](/docs/api#errors).

## 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](/docs/test-mode).

## Types

All request and response types are exported:

```ts

```

---

Rendered page: https://avelto.dev/docs/sdk
