> ## Documentation Index
> Fetch the complete documentation index at: https://ahasend.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> AhaSend stores all message data in the EU. See /facts.
> The API base URL is https://api.ahasend.com. Send the API key in the Authorization: Bearer header. See /api-reference/authentication.
> Use send.ahasend.com for SMTP in the EU, or send-us.ahasend.com as a US connection point forwarding to the EU. Ports 25, 587 and 2525 use STARTTLS. Port 465 is not supported. See /smtp.
> For send-only integrations, create an API Key v2 under Credentials → Add and scope it to messages:send:{your-domain}. See /send-api/credentials and /api-reference/scopes.

# How to Send Email from Vercel Functions

> Send transactional email from Vercel Functions with the AhaSend Node.js SDK: environment variables, sandboxed Preview deployments, and verified webhook routes.

This guide shows how to send transactional email from Vercel Functions with AhaSend, keep credentials on the server, and test a sandbox send.

The AhaSend SDK runs in [Vercel Functions](https://vercel.com/docs/functions).
This guide covers deployment; for complete application handlers, see the
[Next.js](/docs/guides/nextjs) and [Express](/docs/guides/express) guides.

Use a **send-only key** from **Credentials → Add → API Key v2** with `messages:send:{your-domain}`. Use `messages:send:all` when sending from many domains. For management tasks such as creating a webhook, use a separate **full key** from **Account Settings → API Keys** with the needed [scopes](/docs/api-reference/scopes). See [send-only credentials](/docs/send-api/credentials) and [authentication](/docs/api-reference/authentication).

Keep credentials on the server. Use [sandbox mode](/docs/send-api/sandbox) for tests. Reuse a stable, business-event idempotency key only with the exact same payload; use different keys for sandbox and live sends. Check every recipient's status and return a failure when any item is `error`; HTTP 202 alone does not mean every recipient was accepted.

For webhooks, verify the raw request bytes using the full secret as the raw UTF-8 HMAC key, including its `aha-whsec-` or `aha-rsec-` prefix. Never base64-decode the secret. The AhaSend SDK verifiers handle this. These examples use a 30,000,000-byte webhook body limit; a hosting platform may impose a smaller limit. Acknowledge verified unknown event types with 2xx. See [verification](/docs/api-reference/webhooks/security) and [retry policy](/docs/api-reference/webhooks/retry-policy).

## Prerequisites

* A [Vercel account](https://vercel.com) and a project to deploy
* An [AhaSend account](https://dash.ahasend.com/user/register) with a verified sending domain
* An [API key](https://dash.ahasend.com/account/-/transactional/credentials) permitted to send from that domain, and your account ID

## Install the SDK

<CodeGroup>
  ```bash npm theme={null}
  npm install @ahasend/sdk
  ```

  ```bash pnpm theme={null}
  pnpm add @ahasend/sdk
  ```

  ```bash yarn theme={null}
  yarn add @ahasend/sdk
  ```

  ```bash bun theme={null}
  bun add @ahasend/sdk
  ```
</CodeGroup>

## Try a Sandbox Send

Run this short SDK check locally before adding the longer server integration below. Use a [verified sender](/docs/domains), a [send-only API key](/docs/send-api/credentials), and your account ID. It sends to a reserved test address with `sandbox: true`, so no email is delivered.

In the server project directory, add `.env` to `.gitignore`, then create `.env` with `AHASEND_API_KEY`, `AHASEND_ACCOUNT_ID`, and `AHASEND_FROM` (an address on your verified domain). Keep these values out of browser code and AI prompts. Use Node.js 22 or newer. Save this as `quick-send.mjs`:

```js quick-send.mjs theme={null}
import { AhaSendClient } from "@ahasend/sdk";
try {
  const client = AhaSendClient.fromEnv();
  const result = await client.messages.send({
    from: { email: process.env.AHASEND_FROM },
    recipients: [{ email: "recipient@example.com" }],
    subject: "My first sandbox email",
    text_content: "Hello from AhaSend",
    sandbox: true,
  });
  const statuses = result.data.map((entry) => entry.status);
  console.log({ statuses });
  if (!statuses.length || statuses.some((s) => !["queued", "scheduled"].includes(s)))
    throw new Error("A recipient was rejected");
} catch {
  console.error("Sandbox send failed; check your credentials, sender and account.");
  process.exitCode = 1;
}
```

Run `node --env-file=.env quick-send.mjs` from that directory. The output lists each recipient status; `queued` or `scheduled` means accepted for sandbox processing. The process exits with a failure if the request or any recipient fails. Continue below for the full integration.

## Choose a Runtime

Use Vercel's default server runtime; no runtime setting is required for the SDK.
The SDK also supports Web APIs in edge runtimes. Check your installed Next.js
version's [runtime documentation](https://nextjs.org/docs/app/api-reference/edge)
before changing a route's runtime.

<Warning>
  The runtime choice does not change the security boundary. Import the SDK only
  in server code and never expose an API key to browser code.
</Warning>

## Add a Server-Side Send Helper

Create the client at module scope and reuse it. This helper accepts an already
authorized and validated business record; do not call it directly with
untrusted browser input.

```ts lib/ahasend.ts theme={null}
import { AhaSendClient, isAhaSendError } from "@ahasend/sdk";

const ahasend = AhaSendClient.fromEnv();

export type WelcomeInput = {
  signupId: string;
  email: string;
  name?: string;
};

async function welcomeKey(signupId: string, sandbox: boolean): Promise<string> {
  const digest = await crypto.subtle.digest(
    "SHA-256",
    new TextEncoder().encode(signupId),
  );
  const hex = Array.from(new Uint8Array(digest), (byte) =>
    byte.toString(16).padStart(2, "0"),
  ).join("");
  return `welcome-${sandbox ? "sandbox" : "live"}-${hex}`;
}

export async function sendWelcomeEmail(input: WelcomeInput) {
  const sandbox = process.env.AHASEND_SANDBOX === "true";

  try {
    const result = await ahasend.messages.send(
      {
        from: { email: "hello@yourdomain.com", name: "Your App" },
        recipients: [{ email: input.email, name: input.name }],
        subject: "Welcome to Your App!",
        html_content: "<h1>Welcome aboard</h1>",
        text_content: "Welcome aboard",
        sandbox,
      },
      { idempotencyKey: await welcomeKey(input.signupId, sandbox) },
    );

    const rejectedCount = result.data.filter(
      (entry) => entry.status === "error",
    ).length;
    if (rejectedCount > 0) {
      console.error("AhaSend rejected recipients", { rejectedCount });
      throw new Error("Email could not be queued");
    }

    return { queued: result.data.length };
  } catch (error) {
    if (isAhaSendError(error)) {
      const { code, status, requestId } = error.toJSON();
      console.error("AhaSend request failed", { code, status, requestId });
    }
    throw new Error("Email could not be queued");
  }
}
```

Call this helper only after authenticating and authorizing the request. Every
Vercel deployment, Preview included, is served from a public generated URL, so
a route that reaches this helper without an authorization check is an open mail
relay for anyone who finds the URL. For a browser flow, derive `signupId`,
`email`, and `name` from your server-side user record. For a backend-to-backend
route, require a service credential, validate the request body and bound its
size, and apply rate limits. Vercel itself rejects a request body over 4.5 MB
with a 413 (`FUNCTION_PAYLOAD_TOO_LARGE`) before your function runs, which is a
platform ceiling and not a substitute for your own limit. The [Next.js
guide](/docs/guides/nextjs) provides a complete route-handler example.

An accepted send is multi-status: `result.data` holds one result per recipient,
and an entry can have `status: "error"` even when the request resolves. Reuse
the same stable signup ID for retries of the same payload. A stored
non-server-error outcome is replayed for 24 hours; a server error releases the
key for re-execution, so the surrounding workflow must tolerate an uncertain
duplicate.

AhaSend scopes an idempotency key to your account and matches it on the request
method, path, and exact body — never on which API key sent it. That is why
`welcomeKey` puts `sandbox` or `live` in the key itself: without it, a Preview
sandbox send and a Production live send sharing one signup ID would reuse a
single key with two different bodies, which the API rejects as a 422 mismatch.

## Set Environment Variables

<Steps>
  <Step title="Add production variables">
    In **Project → Settings → Environment Variables**, add
    `AHASEND_API_KEY` and `AHASEND_ACCOUNT_ID` for Production, plus
    `AHASEND_WEBHOOK_SECRET` if you use webhooks.
  </Step>

  <Step title="Turn on Sensitive before saving each credential">
    With **Sensitive** enabled, Vercel stores the value unreadably: it cannot
    be retrieved from the dashboard or `vercel env ls` afterwards, only
    replaced. Do this while adding the variable — converting an existing one
    means deleting and re-adding it. Sensitive is available for Production and
    Preview, not Development.
  </Step>

  <Step title="Configure safe previews">
    For Preview, use a separate least-privilege API key and set
    `AHASEND_SANDBOX=true`. Sandbox sends are validated but not delivered. Do
    not make the production API key available to Preview deployments.
  </Step>

  <Step title="Redeploy">
    Environment variable changes apply only to new deployments. Create a new
    deployment after adding, changing, or rotating a value.
  </Step>
</Steps>

You can also add values interactively with the CLI. Name the target
environment on every command: `vercel env add AHASEND_API_KEY` with no
environment offers every environment at once, which is how a Production key
ends up in Preview.

```bash theme={null}
# Production: the key allowed to send real mail
vercel env add AHASEND_API_KEY production
vercel env add AHASEND_ACCOUNT_ID production
vercel env add AHASEND_WEBHOOK_SECRET production

# Preview: a separate restricted key, plus the sandbox switch
vercel env add AHASEND_API_KEY preview
vercel env add AHASEND_ACCOUNT_ID preview
vercel env add AHASEND_SANDBOX preview
```

`vercel env add` stores Production and Preview values as sensitive by default;
pass `--no-sensitive` only if you have a reason to keep a value readable.

<Warning>
  Never give these variables a framework's public prefix — `NEXT_PUBLIC_` in
  Next.js, `VITE_` in Vite, `NUXT_PUBLIC_` in Nuxt. Any of them inlines the
  value into the browser bundle. Rotate an API key immediately if it is exposed.
</Warning>

## Local Development

`vercel dev` downloads Development-scoped variables into memory, so a local
secret file is not required:

```bash theme={null}
vercel dev
```

If you deliberately use `vercel env pull .env.local` instead, ensure
`.env.local` is ignored by version control and never commit it.

As of September 2026, [Vercel documents a 4.5 MB request and response limit](https://vercel.com/docs/functions/limitations). Check that limit before accepting inbound route events with attachments; the host can reject a request before your verifier runs.

## Webhooks on Vercel

Point the webhook created in your [AhaSend
dashboard](https://dash.ahasend.com) at a stable production route such as:

```text theme={null}
https://your-app.com/api/webhooks/ahasend
```

In Next.js, use `nextRouteHandler` from `@ahasend/sdk/webhooks`. It reads and
caps the exact raw request body, verifies the signature and timestamp, and only
then invokes the application handler. The [Next.js guide](/docs/guides/nextjs)
contains the complete handler. Its `maxBodyBytes` option can only narrow the
verifier's 30 MB ceiling — set it to the size your events actually reach, since
Vercel already returns a 413 above 4.5 MB and the default ceiling is far above
anything a webhook delivery needs.

Keep the webhook route on a domain that Deployment Protection leaves reachable.
Standard Protection covers preview and generated deployment URLs but not
production domains, which is what a webhook endpoint needs. If you switch the
project to **All Deployments** protection, the production domain requires Vercel
Authentication and AhaSend's deliveries get a 401. A webhook configuration
carries no custom headers, so the bypass has to travel in the URL: create a
Protection Bypass for Automation secret and append
`?x-vercel-protection-bypass=<secret>` to the webhook URL. Treat that secret as
a credential — it bypasses protection on every deployment in the project until
you rotate it, and signature verification, not the bypass, is what authenticates
the route.

Timestamp validation is not replay deduplication. Before returning a 2xx,
atomically claim the verified `webhook-id` and enqueue durable work (or commit
both through an outbox). Acknowledge a duplicate ID without enqueuing it again,
and make consumers idempotent. Vercel Queues uses at-least-once delivery, so a
queue consumer must also tolerate redelivery.

Do not use an untracked promise for required webhook work. Vercel's
post-response helpers — `after()` from `next/server` on Next.js 15.1 and later,
`waitUntil()` from `@vercel/functions` otherwise — do keep the work inside the
invocation, but they share the function's timeout and their promises are
cancelled if it expires. Use them for non-critical work, not as a replacement
for durable persistence.

## Going Further

For substitutions, attachments and scheduled sends, use the [sending guide](/docs/send-api/send-email). The [Node.js SDK](/docs/guides/nodejs-sdk) and [Go SDK](/docs/guides/go-sdk) references explain client options, per-recipient results and retries. Use [idempotency rules](/docs/api-reference/idempotency) when your application retries a business operation.

## Troubleshooting

<AccordionGroup>
  <Accordion title="SDK import fails in an Edge route">
    Vercel recommends its default server runtime for new functions. Remove an
    unnecessary Edge runtime setting and redeploy. If the route must remain on
    Edge, confirm that the failure comes from another dependency or from using
    CommonJS rather than ES module imports.
  </Accordion>

  <Accordion title="401 from AhaSend after deploying">
    Confirm that the API key and account ID are set for the environment you
    deployed to, that the key can send from `from.email`, and that you created a
    new deployment after changing the variables.
  </Accordion>

  <Accordion title="Preview deployment sends real mail">
    Give Preview its own restricted key, set `AHASEND_SANDBOX=true`, and create
    a new Preview deployment. Rotate the Production key if it was exposed to a
    Preview environment that should not have it.
  </Accordion>

  <Accordion title="Webhook deliveries time out or repeat">
    Verify and atomically persist the delivery before responding, return a 2xx
    promptly, and process the durable work idempotently. Do not rely on an
    untracked promise or timestamp validation to prevent replay.
  </Accordion>
</AccordionGroup>

## Related Guides

* Before sending: [verify a domain](/docs/domains) and [create a send-only key](/docs/send-api/credentials).
* Other ways to send: [REST API](/docs/send-api/send-email), [SMTP](/docs/smtp), [CLI quickstart](/docs/cli/quickstart), [Node.js SDK](/docs/guides/nodejs-sdk) and [Go SDK](/docs/guides/go-sdk).
* Request rules: [API authentication](/docs/api-reference/authentication), [scopes](/docs/api-reference/scopes), [idempotency](/docs/api-reference/idempotency), [errors](/docs/api-reference/errors) and [rate limits](/docs/api-reference/rate-limits).
* Testing and events: [sandbox mode](/docs/send-api/sandbox), [CLI webhook testing](/docs/cli/webhook-testing), [event payloads](/docs/api-reference/webhooks), [signature verification](/docs/api-reference/webhooks/security) and [delivery retries](/docs/api-reference/webhooks/retry-policy).
* Data and limits: [retention](/docs/retention), [tracking](/docs/tracking/open-tracking) and [plans and feature availability](/docs/facts#plans-and-features).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.