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

> Send transactional email from SvelteKit server endpoints with the AhaSend TypeScript SDK, keep keys in private env modules, and verify webhooks.

Keep your AhaSend credentials in SvelteKit's private environment modules and
send email only from server endpoints or server form actions.

## Prerequisites

* A SvelteKit project
* 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) with the `messages:send:{yourdomain.com}` scope for that domain (or `messages:send:all`), 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>

## Configure Environment Variables

Add your credentials to `.env` for local development. The route token protects
the server-to-server example below; generate at least 32 random characters for
it, because that token is the only thing standing between the send endpoint and
an open mail relay.

```bash .env theme={null}
AHASEND_API_KEY=aha-sk-...
AHASEND_ACCOUNT_ID=your-account-uuid
AHASEND_WEBHOOK_SECRET=aha-whsec-...
WELCOME_ROUTE_TOKEN=at-least-32-random-characters
```

`$env/dynamic/private` exposes the private runtime variables supplied by your
deployment platform. With `adapter-node`, these are equivalent to
`process.env`. Configure the same variables in your deployment platform for
production. Values imported from `$env/static/private` are instead injected at
build time.

<Warning>
  Never prefix these values with `PUBLIC_` or import the client into browser
  code. Rotate an API key immediately if it is exposed.
</Warning>

## Create the Client

Build the client on first use and reuse it across requests. SvelteKit prevents
`$lib/server/` modules from being imported into client code, so keep it there:

```ts src/lib/server/ahasend.ts theme={null}
import { env } from "$env/dynamic/private";
import { AhaSendClient } from "@ahasend/sdk";

export function requirePrivateEnv(name: string, minLength = 1): string {
  const value = env[name];
  if (!value || value.length < minLength) {
    throw new Error(`${name} is missing or shorter than ${minLength} characters`);
  }
  return value;
}

let client: AhaSendClient | undefined;

export function ahasend(): AhaSendClient {
  client ??= new AhaSendClient({
    apiKey: requirePrivateEnv("AHASEND_API_KEY"),
    accountId: requirePrivateEnv("AHASEND_ACCOUNT_ID"),
  });
  return client;
}
```

<Warning>
  Read the variables inside the function rather than while the module is
  evaluating. `$env/dynamic/private` is resolved by the platform at runtime, so
  what it holds during a module's first evaluation depends on the adapter — a
  module-scope assertion turns a missing value into a failure of the whole route
  module instead of a failure of one request.
</Warning>

## Send an Email from a SvelteKit Endpoint

This example is a server-to-server endpoint. It authenticates the caller before
reading a bounded request body and requires a stable event ID for safe retries.
AhaSend rate-limits message operations per account (100 requests per second,
with a 200-request burst), so also put a request-rate limit in front of this
route: an authenticated caller must not be able to spend the whole account
budget.

```ts src/routes/api/welcome/+server.ts theme={null}
import { json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { isAhaSendError } from "@ahasend/sdk";
import { ahasend, requirePrivateEnv } from "$lib/server/ahasend";

const MAX_JSON_BYTES = 16 * 1024;

class BodyTooLargeError extends Error {}

type WelcomeInput = {
  email: string;
  name?: string;
  eventId: string;
};

function isWelcomeInput(value: unknown): value is WelcomeInput {
  if (!value || typeof value !== "object") return false;
  const input = value as Record<string, unknown>;
  return (
    typeof input.email === "string" &&
    input.email.length > 0 &&
    input.email.length <= 254 &&
    (input.name === undefined ||
      (typeof input.name === "string" && input.name.length <= 200)) &&
    typeof input.eventId === "string" &&
    /^[A-Za-z0-9._:-]{1,200}$/.test(input.eventId)
  );
}

async function readJson(request: Request): Promise<unknown> {
  if (!request.body) throw new SyntaxError("Missing request body");

  const reader = request.body.getReader();
  const chunks: Uint8Array[] = [];
  let length = 0;

  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      length += value.byteLength;
      if (length > MAX_JSON_BYTES) {
        await reader.cancel();
        throw new BodyTooLargeError();
      }
      chunks.push(value);
    }
  } finally {
    reader.releaseLock();
  }

  const bytes = new Uint8Array(length);
  let offset = 0;
  for (const chunk of chunks) {
    bytes.set(chunk, offset);
    offset += chunk.byteLength;
  }
  return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
}

async function secretsEqual(left: string, right: string): Promise<boolean> {
  const encoder = new TextEncoder();
  const [leftHash, rightHash] = await Promise.all([
    crypto.subtle.digest("SHA-256", encoder.encode(left)),
    crypto.subtle.digest("SHA-256", encoder.encode(right)),
  ]);
  const leftBytes = new Uint8Array(leftHash);
  const rightBytes = new Uint8Array(rightHash);
  let difference = 0;
  for (let i = 0; i < leftBytes.length; i += 1) {
    difference |= leftBytes[i]! ^ rightBytes[i]!;
  }
  return difference === 0;
}

async function isAuthorized(request: Request): Promise<boolean> {
  const authorization = request.headers.get("authorization");
  if (!authorization?.startsWith("Bearer ")) return false;
  return secretsEqual(
    authorization.slice("Bearer ".length),
    requirePrivateEnv("WELCOME_ROUTE_TOKEN", 32),
  );
}

export const POST: RequestHandler = async ({ request }) => {
  if (!(await isAuthorized(request))) {
    return json({ error: "Unauthorized" }, { status: 401 });
  }

  const contentType = request.headers.get("content-type") ?? "";
  if (!/^application\/json(?:\s*;|$)/i.test(contentType)) {
    return json({ error: "Content-Type must be application/json" }, { status: 415 });
  }

  let input: unknown;
  try {
    input = await readJson(request);
  } catch (error) {
    if (error instanceof BodyTooLargeError) {
      return json({ error: "Request body is too large" }, { status: 413 });
    }
    return json({ error: "Invalid JSON" }, { status: 400 });
  }

  if (!isWelcomeInput(input)) {
    return json({ error: "Invalid request" }, { status: 400 });
  }

  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><p>We're glad you're here.</p>",
        text_content: "Welcome aboard! We're glad you're here.",
      },
      { idempotencyKey: `welcome:${input.eventId}` },
    );

    const rejectedCount = result.data.filter((entry) => entry.status === "error").length;
    if (rejectedCount > 0) {
      console.error("AhaSend rejected recipients", { rejectedCount });
      // Deterministic: retrying replays the same rejection, so do not
      // answer with a status that invites one.
      return json({ error: "Email was not accepted for delivery" }, { status: 422 });
    }

    return json({ queued: true }, { status: 202 });
  } catch (error) {
    if (isAhaSendError(error)) {
      const { code, status, requestId, retryAfterSeconds } = error.toJSON();
      console.error("AhaSend request failed", { code, status, requestId });
      if (code === "rate_limit_error") {
        return json(
          { error: "Rate limited" },
          {
            status: 429,
            headers: retryAfterSeconds ? { "retry-after": String(retryAfterSeconds) } : {},
          },
        );
      }
    }
    return json({ error: "Email could not be queued" }, { status: 502 });
  }
};
```

An accepted send is multi-status: `result.data` contains one result per
recipient, and an entry can have `status: "error"` even though the request
resolved. Reuse the same `eventId` when retrying the same business operation;
the stable idempotency key lets AhaSend replay a stored outcome instead of
creating another send. A server-error outcome can be re-executed, so make the
surrounding business workflow tolerate an uncertain duplicate. Do not expose or
log provider errors, recipients, message content, or the idempotency key.

A rate-limit error surfaces only after the SDK has already retried it while
honouring `Retry-After`, so pass that budget on to the caller instead of
flattening it into a generic failure.

For a browser submission, prefer a SvelteKit form action. Authorize it with the
user's server-side session and derive the recipient on the server instead of
trusting a browser-supplied address.

Add `sandbox: true` to validate a send without delivering it. Idempotency keys
are scoped to the account and matched against a hash of the request body, so a
sandbox send is not a separate namespace: flipping `sandbox` while reusing
`welcome:<eventId>` is the same key with a different payload, which the API
rejects with a 422 for the 24 hours the original record lives. Give sandbox
sends their own key prefix.

## Handle Webhooks

`nextRouteHandler` is the SDK's adapter for web-standard `Request`/`Response`
handlers, so it works unchanged in a SvelteKit `+server.ts`. It reads a bounded
raw body and verifies the signature over those exact bytes before invoking your
handler:

```ts src/routes/api/webhooks/ahasend/+server.ts theme={null}
import type { RequestHandler } from "./$types";
import {
  WebhookVerifier,
  isKnownWebhookEvent,
  nextRouteHandler,
} from "@ahasend/sdk/webhooks";
import { requirePrivateEnv } from "$lib/server/ahasend";

let route: ReturnType<typeof nextRouteHandler> | undefined;

export const POST: RequestHandler = ({ request }) => {
  route ??= nextRouteHandler(
    new WebhookVerifier(requirePrivateEnv("AHASEND_WEBHOOK_SECRET")),
    async (event) => {
      if (isKnownWebhookEvent(event)) {
        console.log("AhaSend event", event.type);
      } else {
        // Acknowledge valid event types not yet recognized by this SDK.
        console.log("Unknown AhaSend event", event.type);
      }
      return new Response(null, { status: 200 });
    },
    { maxBodyBytes: 1_000_000 },
  );

  return route(request);
};
```

Pass the webhook secret exactly as the dashboard displays it, including the
`aha-whsec-` prefix. The adapter preserves the exact signed bytes, returns an
opaque error for an invalid or oversized request, and keeps unknown but valid
events on the success path.

`maxBodyBytes` can only narrow the SDK's own ceiling; it cannot raise your
platform's. `adapter-node` rejects a body over `BODY_SIZE_LIMIT` — 512kb by
default — before your handler runs, so raise that variable to match the bound
you choose here, or lower `maxBodyBytes` to match it. Otherwise the platform
produces the rejection and the SDK's bound never applies.

Before doing business work, atomically claim the verified request's
`webhook-id` header in durable storage and enqueue the work in the same
transaction (or use a durable outbox). Acknowledge an already claimed ID and
make downstream processing idempotent. Timestamp validation alone does not
prevent replay within the accepted window.

Commit that claim and enqueue before returning the response. A promise left
running after the handler resolves has no guarantee of completing — serverless
adapters may freeze or discard the invocation as soon as the response is sent,
which acknowledges a delivery whose work never ran and which AhaSend will
therefore never retry.

SvelteKit's CSRF origin check applies only to `POST`, `PUT`, `PATCH`, and
`DELETE` requests whose content type is `application/x-www-form-urlencoded`,
`multipart/form-data`, or `text/plain`, so a JSON webhook delivery reaches this
route untouched. The check is enforced in production but not in local
development, so any cross-origin caller that does use one of those three content
types belongs in `kit.csrf.trustedOrigins` rather than being handled by turning
`checkOrigin` off.

Create the webhook in your [AhaSend dashboard](https://dash.ahasend.com), point
it at `https://your-app.com/api/webhooks/ahasend`, and configure its secret as
`AHASEND_WEBHOOK_SECRET`.

## Going Further

* **Deployment**: choose a server-capable SvelteKit adapter and configure all private environment variables in its deployment platform. A static build cannot run these endpoints.
* **Templating**: pass `substitutions` per recipient and use `{{ variable }}` in the subject or body.
* **Batch sends**: `recipients` accepts up to 100 entries; each gets a separate, individually substituted message.
* **Scheduling**: set `schedule: { first_attempt: new Date(Date.now() + 60_000).toISOString() }` to defer delivery.
* **Attachments**: pass `attachments: [{ data, content_type, file_name, base64: true }]`; binary data must be base64 encoded.

See the [API reference](/docs/api-reference) for every
endpoint the SDK exposes. The same server-side pattern works in the other
meta-frameworks: see the [Next.js](/docs/guides/nextjs) and [Nuxt](/docs/guides/nuxt)
guides.

## Troubleshooting

<AccordionGroup>
  <Accordion title="Cannot import $env/static/private into client-side code">
    You imported the AhaSend client (or an environment module) from a component
    or `+page.ts` that runs in the browser. Move all SDK usage into `+server.ts`,
    `+page.server.ts`, or `$lib/server/` modules. SvelteKit enforces this
    boundary.
  </Accordion>

  <Accordion title="API key visible in the browser bundle">
    Rotate the exposed key in the dashboard. Remove any `PUBLIC_` prefix and
    read the replacement only from `$env/dynamic/private` or
    `$env/static/private` in server-only code.
  </Accordion>

  <Accordion title="Server endpoint is missing after deployment">
    Confirm that the deployment uses a server-capable adapter rather than a
    static build and that its runtime has all four private environment
    variables configured.
  </Accordion>

  <Accordion title="401 from AhaSend">
    The API key is missing, malformed, or revoked. Confirm that
    `AHASEND_API_KEY` and `AHASEND_ACCOUNT_ID` are configured in the production
    environment and that the key still exists in the dashboard.
  </Accordion>

  <Accordion title="403 from AhaSend">
    The key authenticated but lacks the scope for this operation — for a send,
    that is usually `sender domain not found in api key scopes`. Grant the key
    `messages:send:{yourdomain.com}` for the domain in `from.email`, or
    `messages:send:all`.
  </Accordion>
</AccordionGroup>
