# Send Email with Node.js: A Complete API Tutorial

> Send email with Node.js using fetch and an email API: HTML, templates, attachments, batches of 100 and safe retries, with copy-paste code. No SMTP needed.

Canonical: https://www.cresca.xyz/blog/send-email-nodejs
Author: Lalit Kumar Jangid
Published: 2026-10-27
Last updated: 2026-10-27

To **send email with Node.js** you do not need an SMTP server or a mail library. With an email API you make one HTTPS request with the built-in `fetch`: Cresca accepts the email in milliseconds, returns an id, and delivers it from your own domain within seconds. This tutorial goes from a ten-line script to production code: HTML and plain text, templates with variables, attachments, batches of 100, safe retries and an Express route.

**TL;DR**

- `POST https://api.cresca.xyz/api/v1/emails` with `Authorization: Bearer <key>` and a JSON body of `from`, `to`, `subject` and `html`.
- Node.js 18 or later has fetch built in, so there is nothing to install.
- Send an `Idempotency-Key` header and retry only 429, 5xx and network errors.
- Use `POST /emails/batch` for up to 100 emails per request.

## Before you start

1. **Verify a sending domain.** In Cresca, open [Sending Domains](https://app.cresca.xyz/sending-domains), add your domain and publish the DNS records it shows (DKIM, a custom MAIL FROM domain and an ownership record). If your DNS is on Cloudflare, Cresca can publish them for you.
2. **Add a sender** on that domain, for example `hello@acme.com`. Every `from` address you send from must be an active sender on a verified domain.
3. **Create an API key** on the [API keys](https://app.cresca.xyz/api-keys) page. Choose *Sending access* for production: it can send email but cannot read logs or change templates. The key starts with `cr_live_` and is shown once, so store it as the environment variable `CRESCA_API_KEY`.

You need Node.js 18 or later. The examples use ES modules (`.mjs` files, or `"type": "module"` in package.json). The full setup, with screenshots of each step, is in the [email API quickstart](https://www.cresca.xyz/docs/quickstart).

## Send email with Node.js: the basic request

Save this as `send.mjs`, set the key and run `node send.mjs`:

```js
const res = await fetch('https://api.cresca.xyz/api/v1/emails', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.CRESCA_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    from: 'Acme <hello@acme.com>',
    to: 'ana@example.com',
    subject: 'Welcome to Acme',
    html: '<p>Hi Ana, your account is ready.</p>',
  }),
});

const body = await res.json();
if (!res.ok) throw new Error(`${body.name}: ${body.message}`);
console.log(body.id);
```

A successful response is `200 OK` with the email's id. Store it next to the record that triggered the email, so you can look it up later.

```json
{ "id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c" }
```

`to` takes one address or an array. `from` can be a bare address or `Name <address>`. If you send only `html`, Cresca generates the plain-text part for you; send `text` yourself if you want to control it, or send only `text` for a plain email. `cc`, `bcc`, `reply_to`, `tags` and `scheduled_at` (up to 30 days ahead) are optional.

## A production-ready helper with retries

The basic request is fine for a script. In production you want three more things, and the `cresca()` helper below adds all of them:

- **An `Idempotency-Key` header.** If the connection drops after Cresca accepted the email, a retry with the same key returns the first result instead of sending a second email. Keys last 24 hours. Build them from the event, such as `welcome/user-123` or `invoice/inv-2026-0042`, never from the time or a random value. See [idempotency](https://www.cresca.xyz/docs/idempotency).
- **Retries only where they help.** Retry network errors, timeouts, `429 rate_limit_exceeded` and `5xx` with exponential backoff, and wait for the `retry-after` header when Cresca sends it. Do not retry other `4xx` errors: the same request fails the same way.
- **Errors you can branch on.** Every error body has a stable `name` and a readable `message`, for example `{"statusCode": 422, "name": "validation_error", "message": "..."}`. Branch on `name`, because messages can be reworded.

```js
// cresca.mjs: a small client for the Cresca email API (Node.js 18+).
const API_URL = 'https://api.cresca.xyz/api/v1';
const ATTEMPTS = 4;

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// 1s, 2s, 4s... plus up to 250 ms of jitter.
const backoff = (attempt) => 2 ** (attempt - 1) * 1000 + Math.random() * 250;

export class CrescaError extends Error {
  constructor(status, name, message) {
    super(`${status} ${name}: ${message}`);
    this.status = status;
    this.code = name;
  }
}

export async function cresca(path, payload, { idempotencyKey } = {}) {
  for (let attempt = 1; ; attempt++) {
    let res;
    try {
      res = await fetch(API_URL + path, {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.CRESCA_API_KEY}`,
          'Content-Type': 'application/json',
          ...(idempotencyKey && { 'Idempotency-Key': idempotencyKey }),
        },
        body: JSON.stringify(payload),
        signal: AbortSignal.timeout(10_000),
      });
    } catch (err) {
      // Network error or timeout: the idempotency key makes a retry safe.
      if (attempt === ATTEMPTS) throw err;
      await sleep(backoff(attempt));
      continue;
    }

    const body = await res.json().catch(() => ({}));
    if (res.ok) return body;

    const name = body.name ?? 'unknown_error';
    const retryable =
      res.status >= 500 ||
      (res.status === 429 && name !== 'monthly_quota_exceeded') ||
      name === 'concurrent_idempotent_requests';
    if (!retryable || attempt === ATTEMPTS) {
      throw new CrescaError(res.status, name, body.message ?? res.statusText);
    }
    const retryAfter = Number(res.headers.get('retry-after'));
    await sleep(retryAfter > 0 ? retryAfter * 1000 : backoff(attempt));
  }
}
```

Sending an email is now one call, and a timeout or a 429 can never produce a duplicate:

```js
import { cresca } from './cresca.mjs';

const { id } = await cresca(
  '/emails',
  {
    from: 'Acme <hello@acme.com>',
    to: user.email,
    subject: 'Welcome to Acme',
    html: `<p>Hi ${escapeHtml(user.firstName)}, your account is ready.</p>`,
  },
  { idempotencyKey: `welcome/${user.id}` },
);
```

Escape anything a user typed before you put it into HTML (`escapeHtml` stands for your own helper or a library such as `escape-html`). Templates, below, do this for you.

## Send a template with variables

Instead of building HTML in your code, you can design the email in Cresca's template editor, mark the parts your app fills in with triple braces such as `{{{RESET_LINK}}}`, and publish it. Your app then sends only the template alias and the values. Sends always use the *published* version, so you can edit a draft without touching live email. Values are HTML-escaped, so a user's name can never change your markup. A missing variable with no fallback returns `422 validation_error` naming it. More in [templates and variables](https://www.cresca.xyz/docs/templates).

```js
await cresca(
  '/emails',
  {
    to: user.email,
    template: {
      id: 'password-reset', // the template's alias or id
      variables: { NAME: user.firstName, RESET_LINK: resetUrl },
    },
  },
  { idempotencyKey: `password-reset/${resetRequest.id}` },
);
```

`from` and `subject` come from the template unless you pass them. A new password-reset request is a new event, so it gets a new key: a person who asks twice gets two emails.

## Add attachments

Attachments are sent inline as base64: up to 20 files and 7 MB in total per email. Each item has `filename`, `content` and optionally `content_type`. Set `content_id` to show an inline image with `<img src="cid:logo">`. Executable types such as .exe and .js are refused, and attaching by URL is not supported yet.

```js
import { readFile } from 'node:fs/promises';

const pdf = await readFile('invoices/inv-2026-0042.pdf');

await cresca(
  '/emails',
  {
    from: 'Acme Billing <billing@acme.com>',
    to: customer.email,
    subject: 'Your invoice INV-2026-0042',
    html: '<p>Thanks for your payment. Your invoice is attached.</p>',
    attachments: [
      { filename: 'inv-2026-0042.pdf', content: pdf.toString('base64'), content_type: 'application/pdf' },
    ],
  },
  { idempotencyKey: 'invoice/inv-2026-0042' },
);
```

## Send a batch of up to 100 emails

`POST /emails/batch` takes a JSON array of up to 100 emails, each with the same fields as a single send except `attachments` and `scheduled_at`. It is all or nothing: if one email is invalid, nothing is sent and the error message names the failing email by its zero-based index. The response is `{"data": [{"id": "..."}, ...]}` in the same order. One request counts as one request against your rate limit, so batching is how you send thousands of emails per minute.

```js
const emails = users.map((user) => ({
  from: 'Acme <hello@acme.com>',
  to: user.email,
  subject: 'Your weekly summary',
  html: renderSummary(user),
}));

for (let i = 0; i < emails.length; i += 100) {
  const { data } = await cresca('/emails/batch', emails.slice(i, i + 100), {
    idempotencyKey: `weekly-summary/2026-10-27/${i / 100}`,
  });
  console.log(`Batch ${i / 100}: ${data.length} emails accepted`);
}
```

One key per chunk means a crashed job can rerun from the start without sending any chunk twice, as long as it reruns within 24 hours.

## Send email from an Express route

In a web app, send from the server after the work that triggers the email has succeeded. Never call the API from browser code, because that exposes your key.

```js
import express from 'express';
import { cresca, CrescaError } from './cresca.mjs';

const app = express();
app.use(express.json());

app.post('/signup', async (req, res) => {
  const user = await createUser(req.body); // your own code

  try {
    await cresca(
      '/emails',
      { to: user.email, template: { id: 'welcome', variables: { NAME: user.firstName } } },
      { idempotencyKey: `welcome/${user.id}` },
    );
  } catch (err) {
    // The account exists either way; log and let a job retry the email later.
    console.error('Welcome email failed', err instanceof CrescaError ? err.code : err);
  }

  res.status(201).json({ id: user.id });
});

app.listen(3000);
```

For anything slower than a single request, put the send in a job queue such as BullMQ. The idempotency key keeps the job's own retries safe.

## Nodemailer or an email API?

Nodemailer is the usual way to send email with Node.js over SMTP, and it works well with a mail server you already run. An HTTP API is simpler to operate: no SMTP connection pool, no port restrictions on your host, errors as JSON with a stable `name`, and idempotency keys so a retry cannot send twice. Cresca does not offer an SMTP relay yet, so with Cresca you use the API as shown here.

## Errors to handle when you send email with Node.js

These are the errors a sending app actually meets. The full list is on the [errors page](https://www.cresca.xyz/docs/errors).

| Status | name | What it means | Retry? |
| --- | --- | --- | --- |
| 403 | `invalid_from_address` | The from address is not an active sender on a verified domain. | No, fix the sender |
| 422 | `validation_error` | A field is invalid, or a template variable is missing. | No, fix the request |
| 422 | `missing_required_field` | No to, from or subject (and no template that supplies them). | No |
| 429 | `rate_limit_exceeded` | More requests per second than your plan allows. | Yes, after retry-after |
| 429 | `monthly_quota_exceeded` | The plan's email allowance is used up. | No |
| 409 | `concurrent_idempotent_requests` | The same Idempotency-Key is still being processed. | Yes, shortly |
| 5xx | `application_error` | A problem on Cresca's side. | Yes, with backoff |

Recipients on your suppression list (earlier hard bounces, complaints and unsubscribes) are skipped, but the request still succeeds. See [suppression](https://www.cresca.xyz/docs/suppression).

## Check that the email was delivered

A `200` response means Cresca accepted the email, not that it reached the inbox. Open [Transactional](https://app.cresca.xyz/transactional) in the app to see each email's timeline, or look it up with a full-access key:

```bash
curl https://api.cresca.xyz/api/v1/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c \
  -H "Authorization: Bearer $CRESCA_API_KEY"
```

The `last_event` field moves from `queued` to `sent` and then `delivered` or `bounced`. See [email lifecycle](https://www.cresca.xyz/docs/email-lifecycle) for every status.

## Frequently asked questions

### How do I send email with Node.js without SMTP?

Call an email API over HTTPS. With Cresca you POST JSON with from, to, subject and html to https://api.cresca.xyz/api/v1/emails using the fetch built into Node.js 18 and later, with your API key as a bearer token. There is no SMTP server to configure and nothing to install.

### Is there an official Cresca SDK for Node.js?

No, and you do not need one. The API is plain HTTPS and JSON, so the HTTP client you already use in Node.js works. The helper in this tutorial is about 40 lines you can copy into your project and own.

### Can I send email from React or the browser?

No. Calling the API from the browser exposes your API key to anyone who opens the developer tools. Have the frontend call your own server, an Express route or a serverless function, and send the email from there with a sending-access key stored in an environment variable.

### Does it work with TypeScript, Bun and Deno?

Yes. The helper only uses fetch, AbortSignal.timeout and JSON, which are available in Node.js 18 and later, Bun and Deno. Rename it to .ts and add types for the payload if you use TypeScript.

### How many emails can I send per request?

One email can have up to 50 recipients across to, cc and bcc, and a batch request carries up to 100 emails. Requests per second depend on your plan: 2 on Free, 10 on Professional, 20 on Premium, 50 on Ultra and 100 on Enterprise. Each recipient counts as one email against the monthly allowance.

### Is Cresca free to try?

Yes. The Free plan gives you 30 days, 50 contacts and 50 emails with no credit card, which is enough to build and test your integration. API email shares your plan's email allowance with campaigns; paid plans are on the pricing page.

## The same tutorial in other languages

[Python](https://www.cresca.xyz/blog/send-email-python) · [PHP](https://www.cresca.xyz/blog/send-email-php) · [Go](https://www.cresca.xyz/blog/send-email-golang) · [Ruby](https://www.cresca.xyz/blog/send-email-ruby) · [Java](https://www.cresca.xyz/blog/send-email-java) · [C# and .NET](https://www.cresca.xyz/blog/send-email-csharp) · [Next.js](https://www.cresca.xyz/blog/send-email-nextjs) · [Laravel](https://www.cresca.xyz/blog/send-email-laravel) · [Django](https://www.cresca.xyz/blog/send-email-django) · [Ruby on Rails](https://www.cresca.xyz/blog/send-email-rails)

For every field of the Emails API with cURL examples, read the [send email API guide](https://www.cresca.xyz/blog/send-email-api). For the big picture, start with the [transactional email API overview](https://www.cresca.xyz/blog/transactional-email-api). Still deciding how to send? Read [SMTP vs API](https://www.cresca.xyz/blog/smtp-vs-api-sending).

## Start sending

Verify a domain, create a sending-access key and send your first email with Node.js in a few minutes. **[Try Cresca free for 30 days](https://app.cresca.xyz/signup)**, no credit card required, then follow the [quickstart](https://www.cresca.xyz/docs/quickstart) or browse the [API docs](https://www.cresca.xyz/docs).
