• Alerts
  • Node.js
  • Getting started

Amazon SES in Node.js: send email alerts with SDK v3, leave the sandbox and stay out of spam

Send email with Amazon SES in Node.js: SDK v3 code, the sandbox and its 'not verified' error, DKIM, throttling, bounces and the new 2026 pricing plans.

Author
Extenify Team
Published
Reading time
12 min read
A row of weathered metal mailboxes in green, blue and silver standing on a long wooden beam in the sunshine, with shrubs behind them

Amazon SES in Node.js is the cheapest reliable way to send product email from your own backend: password resets, receipts and, the case this guide focuses on, alert emails such as "your extension lost 3% of its users this week" or "a competitor shipped version 4.0." The code is about 20 lines. Almost everything that goes wrong happens around it: the sandbox, an unverified sender, missing DKIM, a throttling error at the worst moment, or a bounce rate that gets your account paused.

The most viewed Stack Overflow question about SES is still Email address is not verified, with more than 260,000 views. This guide walks through the whole path in order, using the AWS SDK for JavaScript v3 and the SES API v2, and includes the pricing change AWS made in July 2026 that many tutorials have not caught up with. Every limit and price below was checked against the AWS documentation on October 14, 2026.

Key takeaways

  • Use @aws-sdk/client-sesv2 and SendEmailCommand. Credentials come from the standard AWS provider chain, never from your code.
  • New accounts start in the sandbox: verified recipients only, 200 messages per 24 hours and 1 message per second. AWS answers a production access request within 96 hours.
  • Since July 21, 2026, new SES accounts start on the Essentials plan at $0.16 per 1,000 emails, while pay-as-you-go à la carte sending is $0.10 per 1,000. The SES-only free tier is gone for new customers.
  • Verify a domain, not just an address, and turn on Easy DKIM. Gmail requires SPF, DKIM and DMARC from anyone sending more than 5,000 messages a day to personal accounts.
  • Keep bounces under 5% and complaints under 0.1%, the levels at which AWS places an account under review.

Before your first email: identities, region and the sandbox

SES settings are per AWS Region. Your sandbox status, quotas, verified identities and pricing plan all live in one Region, so pick it first (usually the one closest to your servers) and use it everywhere.

Then verify what you send from:

  1. Open the SES console, go to Identities, and create a domain identity, for example alerts.example.com. A domain covers every address on it.
  2. Keep Easy DKIM enabled. AWS's Easy DKIM page says SES "automatically adds a 2048-bit DKIM key to every email that you send from that identity." The console gives you three CNAME records to add at your DNS provider; Route 53 can add them for you.
  3. Optionally set a custom MAIL FROM domain, so SPF aligns with your domain instead of amazonses.com.
  4. Publish a DMARC record. A policy of p=none is enough to start, and Google's email sender guidelines accept it.

While the account is in the sandbox, AWS's production access page lists the limits: you can send only to verified addresses and domains or the SES mailbox simulator, "a maximum of 200 messages per 24-hour period," and "a maximum of 1 message per second." That first limit is the source of the famous error:

MessageRejected: Email address is not verified. The following identities failed the check in region US-EAST-1: [email protected]

It does not mean your sender is wrong. It usually means the recipient is not verified and the account is still in the sandbox. During development, verify your own inbox as a recipient. For real users, request production access.

Request production access

In the console, open Account dashboard, then View Get set up page, then Request production access. Choose Transactional for alerts, give your website URL, and confirm that you only email people who asked for it and that you handle bounces and complaints. The same request from the CLI:

aws sesv2 put-account-details \
  --production-access-enabled \
  --mail-type TRANSACTIONAL \
  --website-url https://example.com \
  --additional-contact-email-addresses [email protected] \
  --contact-language EN

AWS says its support team "provides an initial response to your request within 96 hours." A verified domain with DKIM already in place, a real website and a clear description of what users subscribe to all help.

Check your Amazon SES pricing plan (changed in July 2026)

On July 21, 2026, AWS introduced SES pricing plans: Essentials, Pro and Enterprise. AWS's launch post says "Starting July 21, 2026, all new SES accounts begin on the Essentials plan," as do returning customers who had not sent through SES since June 1, 2025. Accounts that sent on or after that date stayed on à la carte pricing.

From the SES pricing page, for the first 10 million emails a month:

Option Outbound price per 1,000 Fixed monthly fee
À la carte $0.10 None
Essentials $0.16 None
Pro $0.22 $105 per account per Region
Enterprise $0.23 $500 per account per Region

Essentials bundles Virtual Deliverability Manager, which costs $0.07 per 1,000 emails on its own. So Essentials is cheaper if you want that deliverability dashboard, and à la carte is cheaper if you only want to send. For a small alerting product sending 100,000 emails a month, that is roughly $10 versus $16 before attachments and data transfer.

To check where you are, look at the Pricing plan page in the console, or read PricingAttributes.CurrentPlan from the GetAccount API. The pricing plans guide explains that choosing Cancel plan returns you to à la carte pricing, and that if you were placed on Essentials by default and never chose a plan yourself, "your first downgrade or cancellation to à la carte pricing also takes effect immediately." Other downgrades wait for the next billing cycle.

Two more pricing notes. The SES-specific free tier of 3,000 message charges a month is no longer offered to new customers since July 21, 2026, though general AWS Free Tier credits still apply. And plans are chosen per Region, so a second Region means a second plan.

Send email with Amazon SES in Node.js using SDK v3

Install the SES v2 client:

npm install @aws-sdk/client-sesv2

Give the process an IAM identity that can call ses:SendEmail on your domain identity, and nothing else. On EC2, ECS or Lambda use the attached role; elsewhere use environment variables or a credentials file that the SDK finds on its own. Never hard-code keys.

// send-alert.mjs (Node.js 20+, @aws-sdk/client-sesv2)
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2';

const ses = new SESv2Client({ region: process.env.AWS_REGION ?? 'us-east-1' });

const FROM = 'Store Alerts <[email protected]>';
const UNSUBSCRIBE_URL = 'https://example.com/alerts/unsubscribe';

export async function sendAlertEmail({ to, subject, text, html, token }) {
  const command = new SendEmailCommand({
    FromEmailAddress: FROM,
    Destination: { ToAddresses: [to] },          // one recipient per call
    ReplyToAddresses: ['[email protected]'],
    Content: {
      Simple: {
        Subject: { Data: subject, Charset: 'UTF-8' },
        Body: {
          Text: { Data: text, Charset: 'UTF-8' },  // always send a plain-text part
          Html: { Data: html, Charset: 'UTF-8' },
        },
        Headers: [
          { Name: 'List-Unsubscribe', Value: `<${UNSUBSCRIBE_URL}?t=${token}>` },
          { Name: 'List-Unsubscribe-Post', Value: 'List-Unsubscribe=One-Click' },
        ],
      },
    },
    ConfigurationSetName: 'alerts',               // for bounce and complaint events
    EmailTags: [{ Name: 'type', Value: 'store-alert' }],
  });

  const { MessageId } = await ses.send(command);
  return MessageId;
}

Why each piece is there:

  • One recipient per call. AWS's sending quota page recommends it: quotas count recipients, and "if the call fails, the entire email is rejected."
  • A plain-text body. Alert emails are mostly numbers and links. A text part reads well in every client and helps with spam filters.
  • Headers for one-click unsubscribe. The Simple message accepts up to 15 custom headers, according to the Message reference. Google says "marketing messages and subscribed messages must support one-click unsubscribe" for senders above 5,000 messages a day, and a subscribed alert digest is a subscribed message. Your unsubscribe URL must accept a POST and also appear as a visible link in the body.
  • A configuration set. It is where bounce, complaint and delivery events get published, which the next sections rely on.
  • The MessageId. Store it next to your own alert ID. AWS's complaint FAQ notes that some providers redact the recipient in complaint reports, so your own mapping is the only way to trace one back.

If you already use Nodemailer, it also has an SES transport that wraps the same client. The direct SDK call above keeps the moving parts visible, which helps when you debug delivery.

Handle throttling and pauses without losing alerts

Production accounts get a daily quota and a per-second rate based on their use case. Read them at startup:

import { SESv2Client, GetAccountCommand } from '@aws-sdk/client-sesv2';

const ses = new SESv2Client({ region: process.env.AWS_REGION ?? 'us-east-1' });
const account = await ses.send(new GetAccountCommand({}));

console.log({
  production: account.ProductionAccessEnabled,   // false means sandbox
  status: account.EnforcementStatus,              // HEALTHY, PROBATION or SHUTDOWN
  plan: account.PricingAttributes?.CurrentPlan,
  maxPerSecond: account.SendQuota?.MaxSendRate,
  max24h: account.SendQuota?.Max24HourSend,
  sent24h: account.SendQuota?.SentLast24Hours,
});

Then pace your sends to MaxSendRate and treat errors by kind:

// deliver.mjs: pace sends and sort SES errors into retry, fix-config and stop
const RETRY = new Set(['TooManyRequestsException', 'ThrottlingException']);
const STOP = new Set(['SendingPausedException', 'AccountSuspendedException']);

export async function deliverAll(emails, maxPerSecond, send) {
  const gapMs = Math.ceil(1000 / Math.max(1, maxPerSecond));
  for (const email of emails) {
    for (let attempt = 1; ; attempt++) {
      try {
        await send(email);
        break;
      } catch (err) {
        if (STOP.has(err.name)) throw err;                  // page a human on another channel
        if (!RETRY.has(err.name) || attempt === 5) {
          console.error('not sent', email.to, err.name, err.message);
          break;                                            // MessageRejected, bad config, etc.
        }
        await new Promise((r) => setTimeout(r, Math.min(60_000, 2 ** attempt * 1000)));
      }
    }
    await new Promise((r) => setTimeout(r, gapMs));
  }
}

A few facts behind that code:

  • AWS's quota errors page says that when you exceed a quota, SES "drops the message and doesn't attempt to redeliver it," with the messages Maximum sending rate exceeded or Daily message quota exceeded, and suggests waiting "up to 10 minutes" before retrying. The SES v2 SendEmail reference lists TooManyRequestsException (HTTP 429), so the code accepts both names.
  • The SDK already retries throttling errors a couple of times on its own. Your loop is the second layer for longer waits.
  • SendingPausedException means SES has paused the account. Retrying will not help. AWS's enforcement FAQ says SES "does not emit an Amazon EventBridge event" for an account-level pause and that you should poll EnforcementStatus from GetAccount instead. Check it in the same job that sends alerts, and report a pause through a channel that does not depend on SES, such as a Telegram bot or a Discord webhook.

Bounces, complaints and the suppression list

Alert emails go to people who signed up, so bounce and complaint rates should be low. AWS's enforcement FAQ is precise about what happens when they are not:

Metric Aim for Account under review Sending may be paused
Bounce rate Below 2% 5% or greater 10% or greater
Complaint rate Below 0.1% 0.1% or greater 0.5% or greater

What helps:

  • Keep the account-level suppression list on. AWS's suppression list guide says accounts that started using SES after November 25, 2019 have it on for both bounces and complaints by default. SES then accepts but does not deliver mail to suppressed addresses.
  • Publish events from the configuration set to Amazon SNS or EventBridge, and mark the user's email as undeliverable in your own database when a hard bounce arrives. Show that in your app so the user can fix a typo.
  • Do not count on complaint data from Gmail. The same guide says "Gmail doesn't provide complaint data to SES." Watch Google Postmaster Tools for your domain, where Google wants spam rates below 0.30% and ideally below 0.10%.
  • Send less. A daily digest of what changed beats ten separate emails. Our guide to store change alerts your team will actually read covers which changes deserve an immediate email and which can wait.

What a good alert email contains

The deliverability work only matters if people read the email. For store and product alerts, a few rules work well:

  • Put the change in the subject: "Weekly users down 12% for Tab Saver" beats "Your daily report."
  • Lead with the number, the previous value and the date range, then one link to the details.
  • Group per product. If five things changed for one extension, send one email.
  • Never send an email that says nothing changed. Skip the day instead. Our post on JSON diff in Node.js shows how to compute what changed between two snapshots so you only email real differences.
  • Schedule it. Digests run on a timer, so the same backend needs a reliable Node.js cron job that does not send twice when you run two instances.

Or let Extenify send the store alerts

If the emails you want are about store listings, Extenify already handles the collection, comparison and delivery. It is a chrome extension analytics and app store rating alerts tool that tracks installs, weekly users, ratings, reviews and versions on the Chrome Web Store, Edge Add-ons, Firefox Add-ons, Google Play and the App Store, for your own products and your competitors, and sends daily change alerts by email, Slack, Discord, Telegram or Microsoft Teams. Every account starts with a 7-day free trial, and the Extenify pricing page lists the plans. For alerts on new reviews specifically, see our guide to Chrome extension review notifications.

Frequently asked questions

Why does Amazon SES say "Email address is not verified"?

In the sandbox, SES only delivers to verified addresses and domains. The error lists the identity that failed, and it is usually the recipient, not the sender. Verify the recipient for testing, or request production access so you can send to anyone. Also check that you verified the identity in the same AWS Region your code uses.

How do I get my Amazon SES account out of the sandbox?

Verify a domain with DKIM, then request production access from the SES console's Get set up page or with aws sesv2 put-account-details --production-access-enabled. Pick Transactional or Marketing, describe what you send and confirm you handle bounces and complaints. AWS says it gives an initial response within 96 hours.

Is Amazon SES free?

Not for new customers. Since July 21, 2026, the SES-specific free tier is no longer offered to new customers, and new accounts start on the Essentials plan at $0.16 per 1,000 emails. You can cancel the plan to pay the à la carte rate of $0.10 per 1,000. New AWS customers can still apply AWS Free Tier credits to SES.

Should I use @aws-sdk/client-ses or @aws-sdk/client-sesv2?

Use @aws-sdk/client-sesv2 for new code. The v2 API covers simple, raw and templated sends in one SendEmail operation, and the newer account features in this guide, such as GetAccount with pricing and enforcement status, the account-level suppression list and custom headers on simple messages, are v2 operations.

What is the Amazon SES sending limit?

In the sandbox, 200 messages per 24 hours and 1 per second. In production, the daily quota and per-second rate depend on your account; read them with GetAccount (SendQuota.Max24HourSend and SendQuota.MaxSendRate) and request increases through AWS Support. Quotas count recipients, not API calls.

Summary

Sending email with Amazon SES in Node.js takes one SendEmailCommand from @aws-sdk/client-sesv2. The real work is around it: verify a domain with Easy DKIM, leave the sandbox with a production access request, check whether your account was placed on the Essentials plan, send one recipient per call with a text part and one-click unsubscribe headers, pace to MaxSendRate, retry throttling and stop on a sending pause, and keep bounces under 5% and complaints under 0.1%. Do that and your alerts land in the inbox, which is the only place they are useful.

Image credits

Put every store in one dashboard tonight.

Sign in with Google, paste one store link, and your unified view is live in under a minute. Then 7 days of Pro free, no credit card required, and you never touch your extension's code.

Start free trial