> ## 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. 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 with Deno and Deno Deploy

> Send transactional email from Deno and Deno Deploy with the AhaSend SDK via npm specifiers: a Deno.serve send route, Deploy secrets, and verified webhooks.

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

Deno Deploy can run npm packages without a Node build step, so you can use the AhaSend SDK directly from a Deno application.

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](https://ahasend.com/docs/api-reference/scopes.md). See [send-only credentials](https://ahasend.com/docs/send-api/credentials.md) and [authentication](https://ahasend.com/docs/api-reference/authentication.md).

Keep credentials on the server. Use [sandbox mode](https://ahasend.com/docs/send-api/sandbox.md) 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](https://ahasend.com/docs/api-reference/webhooks/security.md) and [retry policy](https://ahasend.com/docs/api-reference/webhooks/retry-policy.md).

## Prerequisites

* Deno installed locally, and a [Deno Deploy](https://console.deno.com) organization and application
* An [AhaSend account](https://dash.ahasend.com/user/register) with a verified sending domain
* An [API key](https://dash.ahasend.com/account/-/transactional/credentials) scoped to `messages:send:{your-domain}`, and your account ID
* For webhook side effects, a database operation that can atomically commit a unique `webhook-id` and durable work

## Import the SDK

Add the SDK to your Deno project:

```bash theme={null}
deno add npm:@ahasend/sdk
```

## Try a Sandbox Send

Run this short SDK check locally before adding the longer server integration below. Use a [verified sender](https://ahasend.com/docs/domains.md), a [send-only API key](https://ahasend.com/docs/send-api/credentials.md), 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 Deno 2.x. Save this as `quick-send.mjs`:

```js quick-send.mjs theme={null}
import { AhaSendClient } from "npm:@ahasend/sdk";
try {
  const client = new AhaSendClient({ apiKey: Deno.env.get("AHASEND_API_KEY"), accountId: Deno.env.get("AHASEND_ACCOUNT_ID") });
  const result = await client.messages.send({
    from: { email: Deno.env.get("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.");
  Deno.exit(1);
}
```

Run `deno run --env-file=.env --allow-net=api.ahasend.com --allow-env=AHASEND_API_KEY,AHASEND_ACCOUNT_ID,AHASEND_FROM quick-send.mjs`. Deno resolves the `npm:` import. This is a local SDK check; the server function integration follows below. The output lists recipient statuses and the process fails on a rejected recipient or failed request.

This writes the dependency to `deno.json`. Commit both `deno.json` and `deno.lock`, then import the saved bare specifier:

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

## Configure Environment Variables

On Deno Deploy, add the variables to the application under **Settings → Environment Variables** and mark the API key and webhook secret as **Secrets** (the account ID is not a secret). Attach live credentials only to the Production context. If preview revisions need email tests, give the Development context separate domain-scoped sandbox credentials and a separate webhook secret; do not expose production credentials to code from development branches.

```bash theme={null}
AHASEND_API_KEY=aha-sk-...
AHASEND_ACCOUNT_ID=your-account-uuid
AHASEND_WEBHOOK_SECRET=aha-whsec-...
```

For local development, create `.env` yourself and ensure it is ignored by git before adding values — a local source deployment walks the same `.gitignore`, so that one step is also what keeps the file out of the uploaded bundle. Run with only the permissions this example needs:

```bash theme={null}
deno run --env-file=.env --allow-net=0.0.0.0:8000,api.ahasend.com --allow-env=AHASEND_API_KEY,AHASEND_ACCOUNT_ID,AHASEND_WEBHOOK_SECRET main.ts
```

## Create the Client

Create the client once at module scope and reuse it across requests:

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

export const ahasend = new AhaSendClient({
  apiKey: Deno.env.get("AHASEND_API_KEY")!,
  accountId: Deno.env.get("AHASEND_ACCOUNT_ID")!,
});
```

## Send an Email from a Deno.serve Route

The same `Deno.serve` handler API runs locally and on Deno Deploy, although their permission models differ:

```ts main.ts theme={null}
import { AhaSendAPIError, isAhaSendError } from "@ahasend/sdk";
import { ahasend } from "./lib/ahasend.ts";
import { handleAhaSendWebhook } from "./webhook.ts";
// Application-owned: implement signup lookup and authorization before using real mail.
import { requireAuthenticatedSignup } from "./lib/auth.ts";

Deno.serve(async (req) => {
  const url = new URL(req.url);

  if (req.method === "POST" && url.pathname === "/api/welcome") {
    const user = await requireAuthenticatedSignup(req);
    if (!user) return new Response(null, { status: 401 });

    try {
      const result = await ahasend.messages.send(
        {
          sandbox: true,
          from: { email: "hello@yourdomain.com", name: "Your App" },
          recipients: [{ email: user.email, name: user.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: `sandbox-welcome-${user.signupId}` },
      );

      const rejected = result.data.filter((r) => r.status === "error");
      if (rejected.length > 0) console.warn(`${rejected.length} recipient(s) rejected`);

      return Response.json(
        { statuses: result.data.map((r) => r.status) },
        { status: rejected.length === 0 ? 202 : 502 },
      );
    } catch (err) {
      if (isAhaSendError(err)) {
        console.error("AhaSend request failed", {
          ...(err instanceof AhaSendAPIError ? { status: err.status, requestId: err.requestId } : {}),
          errorCode: err.code,
        });
        return Response.json({ error: "Failed to send email" }, { status: 502 });
      }
      console.error("Unexpected welcome-email handler failure");
      return Response.json({ error: "Failed to send email" }, { status: 500 });
    }
  }

  if (req.method === "POST" && url.pathname === "/webhooks/ahasend") {
    return handleAhaSendWebhook(req);
  }
  return new Response("Not found", { status: 404 });
});
```

`requireAuthenticatedSignup` represents your application's session/authorization check and returns the immutable signup snapshot used for this message. Do not expose an endpoint that accepts an arbitrary recipient from an unauthenticated request; authorize it, validate inputs at the trust boundary, and rate-limit it.

A 202 is **multi-status**: `result.data` carries one entry per recipient, and an individual recipient can come back `status: "error"` with a null `id` (a suppressed address, say) while the call itself succeeds, so check every entry, not just the first.

The SDK retries transient failures automatically, but a retry after a 5xx can still send twice. Reuse a stable `idempotencyKey` only for the exact same request payload. Stored message results expire after 24 hours, so reconcile an uncertain operation rather than assuming a later retry is deduplicated.

The send example uses `sandbox: true` to validate the request without delivering anything. Sandbox and live sends share one idempotency namespace per account, and `sandbox` is part of the request body, so flipping it while reusing a key raises `AhaSendIdempotencyMismatchError` instead of sending.

## Handle Webhooks

The webhooks module lives on its own subpath, so importing it doesn't pull in the API client. `verifier.parse()` accepts Fetch `Headers` and raw bytes and must be awaited. Bound the request while reading its stream, then pass those exact bytes rather than parsing and re-serializing JSON.

The example uses the SDK's 30,000,000-byte ceiling, including for inbound `message.routing` events with attachments. Keep the proxy limit in step and cap concurrent requests. A 413 counts as a failed attempt; [more than 100 consecutive failed attempts disable the webhook](https://ahasend.com/docs/api-reference/webhooks/retry-policy.md).

```ts webhook.ts theme={null}
import { AhaSendWebhookVerificationError, WebhookVerifier, isKnownWebhookEvent } from "@ahasend/sdk/webhooks";
// Application-owned: provide atomic durable storage as described below.
import { webhookDeliveries } from "./lib/webhook-store.ts";

const verifier = new WebhookVerifier(Deno.env.get("AHASEND_WEBHOOK_SECRET")!);
const MAX_WEBHOOK_BODY_BYTES = 30_000_000;

class WebhookBodyTooLargeError extends Error {}

async function readRawBody(req: Request): Promise<Uint8Array> {
  if (!req.body) return new Uint8Array();

  const reader = req.body.getReader();
  const chunks: Uint8Array[] = [];
  let size = 0;
  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      size += value.byteLength;
      if (size > MAX_WEBHOOK_BODY_BYTES) {
        await reader.cancel();
        throw new WebhookBodyTooLargeError();
      }
      chunks.push(value);
    }
  } finally {
    reader.releaseLock();
  }

  const body = new Uint8Array(size);
  let offset = 0;
  for (const chunk of chunks) {
    body.set(chunk, offset);
    offset += chunk.byteLength;
  }
  return body;
}

export async function handleAhaSendWebhook(req: Request): Promise<Response> {
  let event;
  try {
    event = await verifier.parse(req.headers, await readRawBody(req));
  } catch (error) {
    if (error instanceof WebhookBodyTooLargeError) {
      return new Response(null, { status: 413 });
    }
    if (error instanceof AhaSendWebhookVerificationError) {
      return new Response(null, { status: error.reason === "body_too_large" ? 413 : 400 });
    }
    return new Response(null, { status: 500 });
  }

  if (!isKnownWebhookEvent(event)) return new Response(null, { status: 204 });
  const webhookId = req.headers.get("webhook-id");
  if (!webhookId) return new Response(null, { status: 400 });

  try {
    const accepted = await webhookDeliveries.enqueueOnce(webhookId, event);
    return new Response(null, { status: accepted ? 202 : 200 });
  } catch {
    return new Response(null, { status: 500 });
  }
}
```

`webhookDeliveries.enqueueOnce` is application-owned: in one database transaction it must insert the unique verified `webhook-id` and durable work, returning `false` for an already-committed ID. Do not merely insert the ID before doing work, and do not log recipient addresses, event/error objects, signatures, bodies, secrets, or idempotency keys.

Drain that work with idempotent side effects from a separate consumer — a `Deno.cron` job declared at module top level, or a worker outside Deno Deploy. Do not leave it running as a floating promise after you return the response: Deno Deploy keeps an application alive only while requests and responses are still flowing and shuts the isolate down after an idle period, so post-response work is not guaranteed to finish.

Create the webhook in your [AhaSend dashboard](https://dash.ahasend.com) pointing at `https://your-app.your-org.deno.net/webhooks/ahasend`, and copy its secret into `AHASEND_WEBHOOK_SECRET` exactly as shown, including the `aha-whsec-` prefix. Replace the example host with the production domain shown for your Deno Deploy application.

## Deploy

Create or link an application at `console.deno.com`. GitHub-linked applications build on each push. For a local source deployment, make the organization, application, dynamic entrypoint, and production target explicit so a stale CLI context cannot deploy to the wrong app:

```bash theme={null}
deno deploy create --org your-org --app your-app --source local --runtime-mode dynamic --entrypoint main.ts --region global
deno deploy --org your-org --app your-app --prod
```

## Going Further

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

## Troubleshooting

> [accordion] PermissionDenied running locally
>
> Local Deno is permission-scoped: allow the listener and `api.ahasend.com`, plus only the three `AHASEND_*` variables shown above. The managed Deno Deploy runtime currently runs applications with `--allow-all`; custom runtime permission flags cannot be passed there.

> [accordion] 401 AhaSendAuthenticationError
>
> The API key is missing, malformed, or revoked. Locally, confirm `.env` is loaded without printing it. On Deno Deploy, check that the three values are Secrets attached to the revision's Production or Development context, then deploy a new revision.

> [accordion] Webhook verification fails with 400
>
> Pass the bounded raw bytes to `verifier.parse()`, not a re-serialized `JSON.stringify(await req.json())`: re-serialization changes byte layout, so the signature no longer matches. Also confirm the secret includes the `aha-whsec-` prefix.

> [accordion] 400 error mentioning the from address
>
> The `from` address must belong to a verified sending domain on your account. Check domain status in the dashboard.

## Related Guides

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


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