> ## 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 Add Email to Your App with Claude Code

> Use Claude Code to build a transactional email workflow on AhaSend: a CLAUDE.md with the real SDK facts, a planned build, and a sandbox test run.

This guide shows how to send transactional email from an app built with Claude Code with AhaSend, keep credentials on the server, and test a sandbox send.

Claude Code can run the code it writes after you approve the command and permit the required network access, so it can send a test message and read the response back. That turns plausible code into observed-working code, as long as you hand it the real API first.

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

Claude can write and run code, but it can't verify your domain or mint your credentials. Do these two things in the [AhaSend dashboard](https://dash.ahasend.com) first:

1. **Verify a sending domain.** Add the SPF, DKIM, and DMARC records from the [domain setup guide](https://ahasend.com/docs/domains.md). Unverified domains cannot send and produce an API error.
2. **Create an API key.** Follow the [API credentials guide](https://ahasend.com/docs/send-api/credentials.md) and scope it to `messages:send:{your-domain}` rather than `messages:send:all`: if it leaks, the blast radius is one domain's mail, not your whole account. Grab your **account ID** too.

Put both in your environment as `AHASEND_API_KEY` and `AHASEND_ACCOUNT_ID`. If you use the `.env` file shown later, create it yourself, ensure `.env` is ignored by git before adding the values, and do not paste the values into Claude or ask it to read or echo the file. Claude Code persists conversations and tool activity locally. You'll also need Node.js 22 or newer, the SDK's supported floor.

## Step 1: Install the SDK

Do this before you prompt. An agent that finds the dependency already present has far less room to improvise.

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

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

## Step 2: Write a CLAUDE.md With the Real API Facts

Ask Claude cold and you'll get a client from a package nobody has published. Give it the ground truth as standing context instead. In Claude Code, that context lives in a `CLAUDE.md` file at the root of your project, which Claude reads at the start of every session:

```markdown theme={null}
## AhaSend rules for AI coding agents

- Use @ahasend/sdk on the server (Node.js 22+, Deno, Bun, or a supported edge runtime).
- Create a send-only API Key v2 under Credentials → Add, scoped to messages:send:{your-domain}.
  Use messages:send:all only for sending from many domains. Management calls need a separate
  full key from Account Settings → API Keys with the needed scopes.
- Never put API keys in browser code, logs, prompts, or source control. Use server secrets.
- Use AhaSendClient with apiKey and accountId, then await client.messages.send(request, options).
  The v2 path is POST /v2/accounts/{account_id}/messages. from is an object, recipients is an array,
  and content fields are text_content and html_content. Use Create Conversation for CC/BCC.
- Authenticate send routes and take recipients and business-event IDs from trusted server data.
  Add request-size and rate limits. Await sending or commit a durable job before returning success.
- Require AHASEND_DELIVERY_MODE to be sandbox or live; reject missing or other values.
  Use a sandbox-mode credential for agent tests. Never decide delivery mode from NODE_ENV.
- Set sandbox from that explicit mode. Prefix stable business-event idempotency keys with the mode.
  Reuse a key only with the same request. Ordinary results last 24 hours; 5xx may re-execute.
- Inspect every result.data item: queued and scheduled were accepted; error was rejected and has
  a null id. Return a non-2xx result or fail the job when any recipient is rejected.
- Verify webhooks with @ahasend/sdk/webhooks before parsing or acting on untrusted data.
  Await verification. Use the exact raw body and the literal UTF-8 bytes of the full secret,
  prefix included; never base64-decode it. Bound the body to 30,000,000 bytes before reading it.
- Return 2xx for verified unknown events. Commit webhook-id and durable work atomically,
  acknowledge committed duplicates, and retry storage failures. The default clock tolerance
  is five minutes. Never log payloads, recipient addresses, signatures, secrets, or idempotency keys.
- Use a verified sender domain. Schedule the first attempt in the future and within 7 days.
  Encode binary attachment bytes as base64 yourself before setting base64: true.
- Check the API and SDK docs instead of inventing packages, fields, event names, or flags.
  Docs index: https://ahasend.com/docs/llms.txt
```

Claude Code now loads these project instructions each session, but instructions are context rather than a guarantee, so review the generated code against them. In the Claude app, paste the same facts into your message, or, when web search and web fetch are enabled, point Claude at AhaSend's machine-readable doc index at `https://ahasend.com/docs/llms.txt`. The equivalent for an AI editor is a rules file, as in the [Cursor guide](https://ahasend.com/docs/guides/cursor.md).

## Step 3: Ask for the Whole Workflow

Don't ask for a send, ask for the flow, and name the steps:

> "Build a signup-confirmation email workflow. When a new user is created, commit the user and a uniquely keyed welcome-email job in one transaction, then send that job from a worker with the AhaSend SDK, using `substitutions` for their first name and a stable idempotency key derived from the job. Take the recipient address from the stored user record, never from a request body, and never fire the send as a detached promise inside the request handler. Add a webhook endpoint that verifies AhaSend events with `@ahasend/sdk/webhooks`, deduplicates each verified `webhook-id` atomically with durable work, and marks bounced addresses as undeliverable. Read credentials from the environment per CLAUDE.md. Build it in sandbox mode and run it to confirm it works before telling me it's done."

For a multi-file change like this, switch Claude Code into **Plan mode** first. It researches and proposes an approach before writing anything, so you can sign off on the shape before a line of code exists: a plan that separates the send, the template, and the bounce handler instead of one tangled function.

Here is the send the plan should produce:

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

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

// Called from the worker that drains welcome-email jobs, with the user
// loaded from your own records — never from an inbound request body.
export async function sendWelcome(job: {
  id: string;
  user: { email: string; firstName: string };
}) {
  const mode = process.env.AHASEND_DELIVERY_MODE;
  if (mode !== "sandbox" && mode !== "live") throw new Error("Set AHASEND_DELIVERY_MODE to sandbox or live");
  const result = await ahasend.messages.send(
    {
      from: { email: "hello@yourdomain.com", name: "Your App" },
      recipients: [
        { email: job.user.email, substitutions: { first_name: job.user.firstName } },
      ],
      subject: "Welcome {{ first_name }}",
      text_content: "Thanks for signing up, {{ first_name }}.",
      html_content: "<p>Thanks for signing up, {{ first_name }}.</p>",
      sandbox: mode === "sandbox",
    },
    { idempotencyKey: `${mode}-welcome-${job.id}` },
  );

  const rejected = result.data.filter((r) => r.status === "error");
  if (rejected.length > 0) {
    throw new Error("AhaSend rejected a recipient");
  }
  return result;
}
```

The `substitutions` object feeds AhaSend's Jinja-style templating, so `{{ first_name }}` resolves per recipient, which turns a one-off send into a reusable workflow step.

## Step 4: Run It in Sandbox Mode

Claude can execute the script it just wrote after you approve the command and allow outbound HTTPS to `api.ahasend.com`. Ask it to add a small runner and then run it.

```js scripts/send-sandbox.mjs theme={null}
import { AhaSendClient } from "@ahasend/sdk";

const client = new AhaSendClient({
  apiKey: process.env.AHASEND_API_KEY,
  accountId: process.env.AHASEND_ACCOUNT_ID,
});

const result = await client.messages.send({
  from: { email: "hello@yourdomain.com", name: "Your App" },
  recipients: [{ email: "test@example.com", name: "Ada" }],
  subject: "Welcome aboard",
  text_content: "Testing the workflow.",
  sandbox: true,
  sandbox_result: "bounce", // rehearse the failure path
});

console.log(result.data.map((r) => r.status));
```

Run it with `node --env-file=.env scripts/send-sandbox.mjs`. The message goes through validation and processing, triggers relevant configured webhooks, costs nothing, and is never delivered. Claude runs the command, reads the output, and confirms the send was accepted. To verify the handler too, first configure an AhaSend webhook that subscribes to bounce events and points to a URL AhaSend can reach; for local development, expose the handler through a tunnel. Copy the webhook secret into your environment as `AHASEND_WEBHOOK_SECRET` exactly as shown, including its `aha-whsec-` prefix, without pasting it into Claude. Then `sandbox_result: "bounce"` triggers the bounce webhook, and Claude can check the durable result after it arrives. Every outcome is listed in the [sandbox mode guide](https://ahasend.com/docs/send-api/sandbox.md), and each one triggers the matching webhook, so the whole reachable pipeline gets tested, not just the happy path.

Set `AHASEND_DELIVERY_MODE=sandbox` for tests. Set it to `live` explicitly only when ready to deliver; reject missing or other values. Never derive delivery mode from `NODE_ENV`.

## Step 5: Review the Diff

Confirm four things before you merge, even though Claude ran its own tests:

* **The trigger isn't a mail relay.** Find every path that can cause a send and check it is authenticated, validated, and rate-limited, with the recipient address read from your stored user record rather than the request body. A route that mails whatever address a caller posts lets strangers send from your verified domain. Check too that the send is handed to a durable job instead of being detached from the request, where the runtime can kill it after the response.
* **The real package.** The TypeScript import must be `@ahasend/sdk`. Go uses `github.com/AhaSend/ahasend-go`; there is no Python SDK yet, so do not invent one—use the REST API from Python.
* **Every recipient checked.** A promise that resolves means the request was accepted, not that every address was. Code reading `result.data[0]` and moving on will silently miss a suppressed recipient.
* **The key lives in the environment.** Agents that touch many files can drift a secret into the wrong one. Confirm the key is read from `process.env`, never written to anything git tracks, and never assigned to a client-exposed variable (`NEXT_PUBLIC_`, `VITE_`, `PUBLIC_`, `NUXT_PUBLIC_`), which ships it to the browser.

Then merge, set `AHASEND_API_KEY`, `AHASEND_ACCOUNT_ID`, and `AHASEND_WEBHOOK_SECRET` in your hosting platform's secret store, turn `sandbox` off in production, and send one message to an address you control.

> [!WARNING]
>
> Webhook signature verification needs the raw request body. If Claude mounted a global JSON parser in front of the webhook route, verification will fail no matter how correct the secret is. The [Express guide](https://ahasend.com/docs/guides/express.md) shows the correct mounting order.

## 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] Claude wrote code against a package that isn't installed
>
> Add `@ahasend/sdk` to `package.json` yourself before prompting. Run `/context` and check **Memory files** to confirm the intended `CLAUDE.md` loaded; use `/memory` to inspect or edit the configured files. Start a new session if you created the file after the current session began.

> [accordion] The sandbox script fails with a Node runtime error
>
> Check the Node runtime in the terminal Claude is using, not just your shell, since a version manager can leave the agent on a different runtime than your normal session.

> [accordion] Bounce webhook never reaches the handler
>
> Sandbox sends fire the matching webhook, but only if a webhook is configured in the dashboard and pointed at a URL AhaSend can reach. During local development, expose the endpoint through a tunnel and check the secret is copied exactly, including the `aha-whsec-` prefix.

> [accordion] A recipient came back with status error and a null id
>
> That's the multi-status response working as designed: the request was accepted, that address was not. The most common cause is a suppressed address from an earlier hard bounce. Inspect `result.data` entry by entry and check the suppression list in the dashboard.

Every endpoint the SDK exposes is in the [API reference](https://ahasend.com/docs/api-reference.md).

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