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

> Send transactional email on Deno Deploy in minutes with the AhaSend SDK via npm specifiers, plus signed webhook handling, no build step required.

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

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

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 all three as **Secrets**. 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 { 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(
        {
          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: `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 });
    }
  }

  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.

Add `sandbox: true` to the send request to validate it 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.

Size that bound to the events you subscribe to. Delivery-status events are a few kilobytes, but an inbound `message.routing` event embeds base64 attachment data and is routinely much larger. Rejecting an oversized delivery with a 413 counts as a failed delivery, and [100 consecutive failures disable the webhook](/docs/api-reference/webhooks/retry-policy), so raise `MAX_WEBHOOK_BODY_BYTES` toward the SDK's 30,000,000-byte verification ceiling if you route inbound mail.

```ts main.ts theme={null}
import { AhaSendWebhookVerificationError, WebhookVerifier } from "@ahasend/sdk/webhooks";
import { webhookDeliveries } from "./lib/webhook-store.ts";

const verifier = new WebhookVerifier(Deno.env.get("AHASEND_WEBHOOK_SECRET")!);
const MAX_WEBHOOK_BODY_BYTES = 1_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;
}

// inside the Deno.serve handler:
if (req.method === "POST" && url.pathname === "/webhooks/ahasend") {
  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 });
  }

  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

* **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 * 60 * 1000).toISOString() }` to defer delivery by an hour; the first attempt must be in the future and within 7 days.
* **Your own idempotency keys**: pass `{ idempotencyKey: "order-123" }` as the second argument to `send()`, reuse it only for the exact same payload, and account for the 24-hour result-retention window.
* **Attachments**: pass `attachments: [{ data, content_type, file_name, base64: true }]`. Set `base64: true` for binary files such as PDFs.

See the [API reference](/docs/api-reference) for every endpoint the SDK exposes. Running on another modern runtime? Review both the runtime compatibility inventory and the SDK security policy before production; the [Bun](/docs/guides/bun) and [Cloudflare Workers](/docs/guides/cloudflare-workers) guides cover their platform-specific setup.

## Troubleshooting

<AccordionGroup>
  <Accordion title="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>

  <Accordion title="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>

  <Accordion title="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>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>
