Skip to main content
Guide

Send email from a serverless function

Send email from a serverless function on Cloudflare Workers, AWS Lambda or Deno with an SDK that needs no polyfills and no nodejs_compat flag.

mailkube 7 min read Updated August 18, 2026
sdkserverless

You deploy a Worker that sends a welcome email, and it dies on the first request with ReferenceError: process is not defined. Or it builds fine and then fails at runtime because something three levels down the dependency tree imported node:crypto. Most email SDKs were written when Node was the only place server code ran, and the assumption is baked in too deep to configure away.

To send email from a serverless function you need a client that treats the web platform as the baseline rather than as a target to polyfill down to.

The short answer

Install the SDK and construct the client from whatever your platform hands you.

npm install @mailkube/mailkube-node
import { Mailkube } from '@mailkube/mailkube-node';

export default {
  async fetch(request, env) {
    const client = new Mailkube({ apiKey: env.MAILKUBE_API_KEY });

    await client.emails.send({
      from: 'billing@example.com',
      to: 'you@example.com',
      subject: 'Your receipt',
      html: '<p>Thanks for your order.</p>',
    });

    return new Response('ok');
  },
};

That is a complete Cloudflare Worker. There is no compatibility_flags line to add, no bundler alias, no polyfill import.

Why a serverless function needs a different SDK

An edge runtime is not a stripped-down Node. It is a different host that implements the web platform: fetch, Request, Response, AbortSignal, crypto.subtle, TextEncoder, btoa. What it does not implement is Node’s standard library, so node:crypto, Buffer and process are simply absent.

Cloudflare’s answer is the nodejs_compat flag, which polyfills a chunk of that back into scope. It works, and it costs you bundle size, a slower cold start and a permanent dependency on how faithful the polyfill happens to be for the parts you touch.

The SDK avoids the question. It has no runtime dependencies at all and imports nothing from node:. Everything it needs is on the web platform already: fetch for transport, AbortSignal for timeouts and cancellation, crypto.subtle for webhook signatures, TextEncoder and btoa for encoding attachment bytes.

Reading configuration is the one place where a runtime difference leaks through, and it is worth seeing how it is handled, because it explains what to expect from your own code:

export function readEnv(name) {
  try {
    return globalThis.process?.env?.[name];
  } catch {
    return undefined;
  }
}

Both guards earn their place and neither covers the other. The optional chain handles a Worker with no process at all, where naming it bare is a ReferenceError rather than an undefined value. The try/catch handles Deno, which does define globalThis.process, so the chain sails through, and then throws NotCapable: Requires env access unless the script was started with --allow-env. A missing key and an unreadable environment produce the same answer on purpose, because your next move is identical either way.

Send email from a Cloudflare Worker

On Workers the API key arrives per request through the env binding rather than from an ambient environment, so build the client inside fetch rather than at module scope:

import { Mailkube } from '@mailkube/mailkube-node';

export default {
  async fetch(request, env, ctx) {
    const client = new Mailkube({ apiKey: env.MAILKUBE_API_KEY, timeoutMs: 8000 });
    const { to, orderId } = await request.json();

    const email = await client.emails.send({
      from: 'billing@example.com',
      to,
      subject: 'Your receipt',
      html: '<p>Thanks for your order.</p>',
      idempotencyKey: `receipt-${orderId}`,
      tags: [{ name: 'kind', value: 'receipt' }],
    });

    return Response.json({ id: email.id });
  },
};

Set your API key as a secret rather than a plain variable:

npx wrangler secret put MAILKUBE_API_KEY

Note timeoutMs. The client defaults to 30 seconds, which is longer than the wall-clock budget of most edge invocations. Setting it below your platform’s limit means you get a ConnectionError you can handle instead of the platform killing the whole invocation out from under you.

Send email from AWS Lambda and Cloud Functions

These are ordinary Node runtimes, so nothing special is required, but the execution environment is reused between invocations. Build the client once at module scope and it survives cold start to cold start instead of being rebuilt per request:

import { Mailkube, RateLimitError } from '@mailkube/mailkube-node';

const client = new Mailkube();   // module scope, built once per cold start

export const handler = async (event) => {
  try {
    const email = await client.emails.send({
      from: 'billing@example.com',
      to: event.to,
      subject: event.subject,
      html: event.html,
      idempotencyKey: event.requestId,
    });

    return { statusCode: 202, body: JSON.stringify({ id: email.id }) };
  } catch (error) {
    if (error instanceof RateLimitError) {
      return {
        statusCode: 429,
        headers: { 'retry-after': String(error.retryAfter ?? 60) },
        body: JSON.stringify({ error: error.errorName }),
      };
    }
    throw error;
  }
};

Two things in there are specific to serverless. The idempotencyKey is the invocation’s own request id, because Lambda retries on failure and you would rather that produce one message than two. And the rate-limit branch hands the backoff upward rather than sleeping: waiting inside a function is billed by the millisecond, and the caller is better placed to decide whether to retry at all.

The SDK has no built-in retries, deliberately. A RateLimitError carries retryAfter in seconds and a ServerError is safe to retry with backoff, so the decision stays with the code that knows what it is allowed to cost.

Google Cloud Functions is the same shape with a different handler signature. Deno and Bun need no changes either:

import { Mailkube } from "npm:@mailkube/mailkube-node";
bun add @mailkube/mailkube-node

Verifying webhooks at the edge

Delivery events, bounces, opens and clicks arrive as signed webhooks, and a receiver is the other half of most serverless email setups. Verification runs on crypto.subtle, so it works on every runtime above:

import { verify } from '@mailkube/mailkube-node';

export default {
  async fetch(request, env) {
    const body = new Uint8Array(await request.arrayBuffer());
    const event = await verify(body, request.headers, env.MAILKUBE_WEBHOOK_SECRET);

    switch (event.type) {
      case 'email.bounced':
        console.log(event.data.bounce.reason, event.data.bounce.code);
        break;
      case 'email.clicked':
        console.log(event.data.click.link);
        break;
      default:
        break;
    }

    return new Response(null, { status: 204 });
  },
};

Read the body as bytes with arrayBuffer, never as parsed JSON. The signature covers the exact bytes that arrived, and any parse and re-serialize step changes them.

verify is asynchronous, and that is a constraint rather than a style choice. crypto.subtle is the only digest API all four runtimes share, and it has no synchronous equivalent, so there is no version of this that returns a value directly.

One detail worth knowing if you are auditing this yourself: the SDK computes the expected signature and compares it in constant time rather than calling crypto.subtle.verify. The Web Crypto specification places no timing requirement on verify, so a comparison there is not guaranteed to be safe against a timing attack.

Retries reuse the same X-Webhook-Id, so store it and treat a repeat as already handled.

Common mistakes

  • Adding nodejs_compat to make an error go away. It polyfills process, Buffer and node:crypto back into scope, which hides the fact that something in your bundle still needs them. Find what does.
  • Passing a Buffer as attachment content. Attachments take a base64 string or a Uint8Array. Buffer is a Node type and does not exist on Workers, and btoa covers the string case anywhere.
  • Building the client per invocation on Lambda. The container is reused, so module scope is free. On Workers the opposite holds, because the key arrives with the request.
  • Sleeping on a 429 inside the function. You pay for the wait and you may exceed the invocation budget anyway. Return the retryAfter value and let the caller come back.
  • Leaving the default 30 second timeout under a shorter function budget. The platform kills the invocation before the client ever raises anything you can catch. Set timeoutMs below your limit.

Where to go next

The Node SDK reference lists every parameter and the full webhook event catalogue. If you are starting from scratch, sending your first email from Node.js covers domain verification and the first send, and sending email from Express, Fastify, Next.js and NestJS covers the same ground for a long-running server.

Start sending in minutes

Create an account, verify a domain, and send your first message today.

Get started