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

> Send transactional email from a Hono app on Node.js or Bun with the AhaSend TypeScript SDK: protected send route, idempotent retries, and signed webhooks.

This guide shows how to use AhaSend to send transactional email from Hono in Node.js, keep credentials on the server, and test a sandbox send.

This guide builds a Hono server on Node.js and adds a protected email endpoint plus signed webhook handling. Hono also supports other runtimes, whose entry points and secret bindings differ; see the [Bun guide](/docs/guides/bun) or [Cloudflare Workers guide](/docs/guides/cloudflare-workers) for those platform-specific details.

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

* Node.js 22 or newer (the SDK's minimum)
* An [AhaSend account](https://dash.ahasend.com/user/register) with a verified sending domain
* An [API key](https://dash.ahasend.com/account/-/transactional/credentials) with the `messages:send:{your-domain}` scope (`messages:send:all` for sending from many domains), and your account ID

## Install the SDK

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

  ```bash pnpm theme={null}
  pnpm add @ahasend/sdk hono @hono/node-server
  ```

  ```bash yarn theme={null}
  yarn add @ahasend/sdk hono @hono/node-server
  ```

  ```bash bun theme={null}
  bun add @ahasend/sdk hono @hono/node-server
  ```
</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.

On Bun you don't need `@hono/node-server`: `Bun.serve` runs Hono natively via `export default app`.

## Configure Environment Variables

Add your credentials to `.env` (and load them with `node --env-file=.env`; Bun loads `.env` automatically):

```bash .env theme={null}
AHASEND_API_KEY=aha-sk-...
AHASEND_ACCOUNT_ID=your-account-uuid
AHASEND_WEBHOOK_SECRET=aha-whsec-...
WELCOME_ROUTE_TOKEN=replace-with-a-long-random-token
```

## 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 function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing required environment variable: ${name}`);
  return value;
}

export const ahasend = new AhaSendClient({
  apiKey: requireEnv("AHASEND_API_KEY"),
  accountId: requireEnv("AHASEND_ACCOUNT_ID"),
});
```

## Send an Email from a Hono Route

```ts server.ts theme={null}
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { bearerAuth } from "hono/bearer-auth";
import { bodyLimit } from "hono/body-limit";
import { AhaSendAPIError } from "@ahasend/sdk";
import { ahasend, requireEnv } from "./lib/ahasend.js";

const app = new Hono();

app.use(
  "/api/*",
  bearerAuth({ token: requireEnv("WELCOME_ROUTE_TOKEN") }),
);

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

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

app.post(
  "/api/welcome",
  bodyLimit({
    maxSize: 16 * 1024,
    onError: (c) => c.json({ error: "Request body is too large" }, 413),
  }),
  async (c) => {
    let input: unknown;
    try {
      input = await c.req.json();
    } catch {
      return c.json({ error: "Request body must be valid JSON" }, 400);
    }

    if (!isWelcomeInput(input)) {
      return c.json({ error: "Invalid email, name, or eventId" }, 400);
    }

    try {
      const result = await ahasend.messages.send(
        {
          sandbox: true,
          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: `sandbox-welcome-${input.eventId}` },
      );

      const rejectedCount = result.data.filter((r) => r.status === "error").length;
      if (rejectedCount > 0) {
        console.warn("AhaSend rejected recipients", { rejectedCount });
        return c.json({ error: "Email was not accepted" }, 502);
      }

      return c.json({ statuses: result.data.map((r) => r.status) }, 202);
    } catch (err) {
      if (err instanceof AhaSendAPIError) {
        console.error("AhaSend request failed", {
          status: err.status,
          requestId: err.requestId,
          errorCode: err.code,
        });
        return c.json({ error: "Failed to send email" }, 502);
      }
      throw err;
    }
  },
);

const port = Number(process.env.PORT ?? 3000);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
  throw new Error("PORT must be an integer from 1 to 65535");
}
serve({ fetch: app.fetch, port });
```

Call the route from a trusted backend with `Authorization: Bearer <WELCOME_ROUTE_TOKEN>`, and use a stable `eventId` for the business event that should send exactly one welcome email. Do not expose this route token to browser code, and put rate limiting in front of the route at your proxy or gateway: the token is the only thing between a caller and your sending quota, and the route will mail any address it is handed. Replace the token check with your application's normal authentication and authorization if the endpoint is user-facing.

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. The stable key in the example is reused when your application retries the same business event; reuse it only with the exact same message payload. A key can expire, so it does not replace application-level state that records whether the welcome email was sent.

The send example uses `sandbox: true` to validate the request without delivering anything. `sandbox` is part of the request body, and the server matches an idempotency key against a hash of that body, so a key already used for a sandbox send is rejected with a 422 when the same key is replayed for the live send — give the two runs different keys.

## Handle Webhooks

`nextRouteHandler` is the SDK's web-standard Request/Response adapter, so it works with Hono's `c.req.raw`.

Pass Hono's raw Web `Request` to the SDK's web-standard adapter. The adapter reads the original bytes, enforces the configured size limit, verifies the signature and timestamp, parses the typed event, and returns opaque `400` or `413` responses for invalid or oversized deliveries:

```ts server.ts theme={null}
// Add to the imports already at the top of server.ts; `requireEnv` is
// imported there.
import {
  WebhookVerifier,
  isKnownWebhookEvent,
  nextRouteHandler,
} from "@ahasend/sdk/webhooks";

const verifier = new WebhookVerifier(requireEnv("AHASEND_WEBHOOK_SECRET"));

const ahasendWebhook = nextRouteHandler(
  verifier,
  async (event, request) => {
    // Verification already required a non-empty webhook-id. Commit this ID and
    // your durable work in one transaction before acting on the event.
    const webhookId = request.headers.get("webhook-id");

    if (isKnownWebhookEvent(event)) {
      switch (event.type) {
        case "message.delivered":
        case "message.bounced":
        case "message.opened":
          console.log("Received AhaSend webhook", { webhookId, type: event.type });
          break;
      }
    }

    return new Response(null, { status: 200 });
  },
  { maxBodyBytes: 30_000_000 },
);

app.post("/webhooks/ahasend", (c) => ahasendWebhook(c.req.raw));
```

Add this route before the `serve()` call. Signature timestamp checking does not prevent a valid delivery from being replayed within the accepted window. Before performing a side effect, atomically store the verified `webhook-id` — read from the handler's second `Request` argument, as above — together with durable work in one transaction, acknowledge an ID that transaction already holds without re-enqueueing it, and make the work itself idempotent.

Let a failed commit reject: the adapter rethrows a handler error, Hono answers 500, and AhaSend retries. Never catch it into a 200. Every non-2xx answer counts as a failed delivery — [delivered with 6 attempts over about 16 minutes, with the webhook disabled after more than 100 consecutive failed attempts](/docs/api-reference/webhooks/retry-policy) — so the opaque 400 the adapter returns for a bad signature quietly spends that budget on every delivery until the secret is fixed.

Create the webhook in your [AhaSend dashboard](https://dash.ahasend.com) pointing at `https://your-app.com/webhooks/ahasend`, and copy its secret into `AHASEND_WEBHOOK_SECRET` exactly as shown (including the `aha-whsec-` prefix).

## 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="Webhook verification always returns 400">
    Verify `AHASEND_WEBHOOK_SECRET` matches the dashboard value exactly (including the `aha-whsec-` prefix), and make sure nothing consumes or rewrites the request body before `ahasendWebhook`: even reformatting the JSON invalidates the HMAC.
  </Accordion>

  <Accordion title="401 AhaSendAuthenticationError">
    The API key is missing, malformed, or revoked. Verify that `AHASEND_API_KEY` is loaded without logging any part of it. On Node, remember to start with `node --env-file=.env`.
  </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>

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