Notix
Integrations

Send email from a Lambda, with no sandbox to leave.

A Lambda function sends through the Notix email API the same way any other process does: one HTTPS call with a Bearer token. What Lambda adds is its own retry policy, a short default timeout, a secrets story and a VPC egress trap, and those are what this page is about. The handler comes first in Node.js, Python and Go; the four Lambda-specific problems follow, each with the fix.

The handler

Client outside, send inside.

Create the client at module scope so a warm environment reuses it. Read the key from the environment for now; the secrets section below moves it to Secrets Manager. The idempotency key is built from the event, so the retries described further down cannot send twice.

handler.mjs (Node.js 20 runtime)
import { Notix } from "notix-js";

// Module scope: created once per execution environment, reused across
// warm invocations. Never inside the handler.
const notix = new Notix(process.env.NOTIX_API_KEY);

export const handler = async (event) => {
  // An SQS batch, an EventBridge event, a direct invoke: read your own
  // shape here. One record per email keeps the idempotency key simple.
  const order = JSON.parse(event.Records?.[0]?.body ?? JSON.stringify(event));

  const { data, error } = await notix.emails.send(
    {
      from: "receipts@acme.com",
      to: order.email,
      subject: `Your receipt for order ${order.id}`,
      html: `<p>Thanks for your order ${order.id}.</p>`,
      text: `Thanks for your order ${order.id}.`,
    },
    // Lambda retries a failed async invocation twice and SQS redelivers.
    // The same key on every attempt means at most one email per order.
    { idempotencyKey: `order-${order.id}-receipt` },
  );

  if (error) {
    // Throwing makes Lambda retry (async) or leave the message on the
    // queue (SQS). Only do that for errors a retry can fix.
    if (error.code === "RATE_LIMITED") throw new Error(error.message);
    console.error("send refused", error.code, error.message);
    return { sent: false, code: error.code };
  }

  return { sent: true, emailId: data.emailId };
};

Install with npm install notix-js, pip install notix or go get github.com/notix/notix-go. The Go package does not set Idempotency-Key yet; the Go guide shows the same send with net/http and the header.

Versus SES

What you skip by not going through SES.

No sandbox request.

A new SES account can only send to addresses you have verified until AWS approves a production-access request, which it reviews and can decline. A Notix account sends to anyone from a verified domain on the first call; the free plan needs no card.

No per-region identities.

SES verifies identities per region, so a function deployed in a second region verifies again. Notix domains are verified once and the API answers from anywhere your function runs.

No SNS topic for events.

Delivery, bounce and complaint events reach you as signed webhooks you register in the dashboard, not as SNS notifications you subscribe a second function to and parse yourself.

Where SES still wins.

Per email, SES is cheaper by a wide margin, and a platform team that will build the log, the event pipeline and the suppression checks anyway should read SES directly, or a layer on top before choosing. The SES alternative page puts the numbers side by side.

Retries

Lambda will run your handler again. Plan for it.

An asynchronous invocation that fails is retried 2 more times, a minute and then two minutes later, and a timeout counts as a failure. An SQS-triggered function that errors sees the same message again once its visibility timeout expires. If attempt one reached Notix and then the function died, attempts two and three would send the receipt again.

Derive the key from the event.

order-4471-receipt, reset-<userId>-<tokenId>, or the SQS messageId when nothing better exists. The same key and body on a retry returns the original emailId with 200 instead of sending; the same key with a different body answers 409. Keys are up to 256 characters and expire after 24 hours, long after the retries have stopped.

Throw only for retryable errors.

A transport failure or RATE_LIMITED deserves the retry. An unverified from domain, a key without sending access or a suppressed recipient fails the same way every time; log it and return so the record leaves the queue instead of ending in a dead-letter queue.

Secrets

Keep the key out of the template.

An environment variable is encrypted at rest and fine for a first deploy. For production, hold the key in Secrets Manager or SSM Parameter Store and read it through the AWS Parameters and Secrets Lambda Extension, which serves both over localhost and caches for 300 seconds by default. Read once at module scope and a warm function never touches the secrets API. Use a sending access key: it cannot read contacts or delete domains if it leaks.

handler.mjs, key from Secrets Manager
// The AWS Parameters and Secrets Lambda Extension serves Secrets Manager and
// SSM Parameter Store over localhost and caches for 300 seconds by default,
// so a warm function does not call the secrets API on every invocation.
// Add the extension as a layer, then read the key once at module scope.
import { Notix } from "notix-js";

async function readSecret(secretId) {
  const response = await fetch(
    `http://localhost:2773/secretsmanager/get?secretId=${encodeURIComponent(secretId)}`,
    { headers: { "X-Aws-Parameters-Secrets-Token": process.env.AWS_SESSION_TOKEN } },
  );
  if (!response.ok) throw new Error(`secret read failed: ${response.status}`);
  const { SecretString } = await response.json();
  return SecretString;
}

// Top-level await runs once per execution environment.
const notix = new Notix(await readSecret(process.env.NOTIX_API_KEY_SECRET_ID));

export const handler = async (event) => {
  // ... same send as above
};
Timeouts and networking

The two settings that make a working call fail.

A 3 second default timeout.

A cold start, a TLS handshake and one API call can take longer than the default. Set the timeout to 10 or 15 seconds (the ceiling is 900), and give the SDK’s own request timeout a smaller value than the function’s so the handler, not Lambda, decides what happens on a slow call.

A VPC with no way out.

Attach a function to a VPC and all of its outbound traffic goes through that VPC. Without a NAT gateway in a public subnet, and the function in a private subnet routed to it, app.usenotix.dev is unreachable and the call hangs until the timeout. A public subnet does not help: a Lambda network interface has no public IP. If the function does not need RDS or ElastiCache, leave it out of the VPC.

Deploy

The environment variable, in SAM and in CDK.

Both snippets set the timeout above the default and hand the function its key: SAM by resolving a Secrets Manager value at deploy time, CDK by granting read on the secret and passing its name for the extension to fetch.

template.yaml
# template.yaml (AWS SAM)
Resources:
  SendReceipt:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: nodejs20.x
      Handler: handler.handler
      # The default is 3 seconds. One HTTPS call plus a cold start wants more.
      Timeout: 15
      Environment:
        Variables:
          # Plain env var for a first deploy. For production, put the key in
          # Secrets Manager and pass its id here instead (see the extension).
          NOTIX_API_KEY: "{{resolve:secretsmanager:notix/api-key:SecretString:key}}"
      Events:
        Orders:
          Type: SQS
          Properties:
            Queue: !GetAtt OrdersQueue.Arn
            BatchSize: 1
stack.ts
// AWS CDK (TypeScript)
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as secrets from "aws-cdk-lib/aws-secretsmanager";
import { Duration } from "aws-cdk-lib";

const apiKey = secrets.Secret.fromSecretNameV2(this, "NotixApiKey", "notix/api-key");

const fn = new lambda.Function(this, "SendReceipt", {
  runtime: lambda.Runtime.NODEJS_20_X,
  handler: "handler.handler",
  code: lambda.Code.fromAsset("dist"),
  timeout: Duration.seconds(15),
  environment: {
    // The function reads the value through the secrets extension at start.
    NOTIX_API_KEY_SECRET_ID: apiKey.secretName,
  },
});
apiKey.grantRead(fn);

Delivery status for every send arrives on a webhook, which can itself be a Lambda behind a function URL or API Gateway; the Node.js guide shows the signature check, and the docs list every event.

FAQ

Questions from people deploying this.

Can I send email from AWS Lambda without Amazon SES?
Yes. A Lambda function can call any HTTPS API, and the Notix email API is one POST to /api/v1/emails with a Bearer token. There is no sandbox to request your way out of, no per-region identity verification and no SNS topic to wire for bounces: verify your domain once in the Notix dashboard, put an API key in the function's environment or Secrets Manager, and the handlers on this page send from the first invocation. The SES comparison page sets out honestly when SES itself is the better fit.
Why does my Lambda send the same email two or three times?
Because Lambda retried it. An asynchronous invocation that errors, including a timeout, is retried twice by default, and an SQS-triggered function that errors sees the message again once its visibility timeout expires. If the first attempt reached Notix and then the function timed out, the retries send again. Pass an idempotency key built from the event, such as order-4471-receipt: the same key with the same body returns the original email instead of sending a new one.
Where should the API key live?
A plain environment variable works and is encrypted at rest by Lambda, so it is fine for a first deploy. For production, keep the key in AWS Secrets Manager or SSM Parameter Store, add the AWS Parameters and Secrets Lambda Extension as a layer, and read the value once at module scope over the extension's localhost endpoint; it caches for five minutes by default, so warm invocations never call the secrets API. Give the function a sending-access key, which cannot read contacts or delete domains if it leaks.
My function times out calling the API, but the same code works locally.
Two usual causes. The default function timeout is 3 seconds, which a cold start plus a TLS handshake plus one API call can exceed; set 10 to 15 seconds. Or the function is attached to a VPC: then every outbound packet goes through that VPC, and without a NAT gateway in a public subnet, with the function in a private subnet, there is no route to the internet at all. A function that does not need VPC resources should not be in a VPC.
Should the handler throw when the send fails?
Only when a retry can help. A transport failure or a 429 RATE_LIMITED is worth throwing for, because Lambda's retry (or the SQS redelivery) will likely succeed a minute later. A 400 for an unverified from domain, a 403 for a key without sending access, or a 422 for a suppressed address will fail identically every time; log the code, return normally, and let the record leave the queue rather than burning retries and landing in a dead-letter queue for nothing.
Does a warm Lambda reuse the connection?
It reuses whatever you create outside the handler. Construct the Notix client at module scope, in Python at import time and in Go in init or a package-level var, and it lives for the life of the execution environment. Node's fetch keeps the TLS connection alive between warm invocations; creating the client inside the handler throws that away on every call and adds a handshake each time.

Deploy the handler this afternoon.

Verify a domain, create a sending-access key, paste the handler. No sandbox, no card.