> ## 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.

# How to Send Email with Vercel

> Deploy email-sending apps to Vercel with the AhaSend SDK, environment variables, safe preview deployments, and verified webhook endpoints.

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.

## 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/-/settings/api-keys) 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>

## Choose a Runtime

Use Vercel's default server runtime; no runtime setting is required for the SDK.
Vercel recommends migrating from the Edge runtime to Node.js, and Next.js 16.3
and later no longer accept `runtime = 'edge'` at all. If an older route still
uses the Edge runtime, the SDK works there too using the runtime's Web APIs.

<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.

## Webhooks on Vercel

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

```
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

* **Framework detail**: the [Next.js guide](/docs/guides/nextjs) covers authenticated route handlers, server actions, and the `nextRouteHandler` webhook adapter.
* **Client configuration**: see the [Node.js SDK guide](/docs/guides/nodejs-sdk) for timeouts, retries, and idempotency options.
* **Durable processing**: use a durable queue or outbox for webhook work, and deduplicate both webhook deliveries and at-least-once queue messages.
* **Safer previews**: keep `AHASEND_SANDBOX=true` in Preview and use credentials separate from Production.
* **Attachments**: pass `attachments: [{ data, content_type, file_name, base64: true }]`; binary data must be base64 encoded.

## 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>
