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

> Add transactional email with Windsurf Cascade: an AhaSend rules file, a replayable /ahasend-email Workflow, a diff checklist, and sandbox testing.

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

Brief Cascade well and it will wire up email correctly, then you brief it again from scratch on the next project. Windsurf's Rules and Workflows files turn that briefing into something you replay instead.

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

Cascade writes and runs code, but it can't verify your domain or create your credentials. Do these 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), then confirm the domain is verified before sending.

**2. Create an API Key**

Follow the [API credentials guide](https://ahasend.com/docs/send-api/credentials.md) and use a domain scope such as `messages:send:{example.com}` instead of `messages:send:all`: if it leaks, the damage is limited to one domain's outbound mail. Domain-specific [scopes](https://ahasend.com/docs/api-reference/scopes.md) are written with the curly braces around the domain. Grab your **account ID** too.

**3. Put Both in Your Environment**

For local agent-run tests, create a dedicated, domain-scoped credential in **Sandbox** mode. Set its `AHASEND_API_KEY`, `AHASEND_ACCOUNT_ID`, and `AHASEND_DELIVERY_MODE=sandbox` in `.env`, and keep that file out of git. Configure live credentials in your deployment's secret manager rather than giving them to an agent-run test.

## Step 1: Install the SDK Before You Prompt

Do this yourself. An agent that finds the dependency already in `package.json` has far less room to improvise a different one.

```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: Give Cascade the Ground Truth with a Rules File

Give Cascade an authoritative project Rule instead of relying on generated code to reconstruct the integration from general context. Cascade can also search documentation, but a Rule keeps the critical constraints available inside the repository.

[Current Windsurf/Devin Desktop docs](https://docs.devin.ai/desktop/cascade/memories) confirm that Workspace Rules are Markdown files with an activation mode, discovered under `.devin/rules/` in your workspace — the preferred location in current Windsurf builds, with the older `.windsurf/rules/` still read as a fallback. Create a model-decision rule so Cascade loads the full instructions when its description matches an email task:

Use this frontmatter, followed by the shared rules below:

```yaml theme={null}
---
trigger: model_decision
description: AhaSend email integration rules
---
```

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

To start from a draft, point Cascade at AhaSend's machine-readable doc index at `https://ahasend.com/docs/llms.txt`, ask it to write the rule from those pages, then check the result against the list above.

## Step 3: Capture the Integration as a Workflow

A Workflow is a Markdown file under `.windsurf/workflows/` with a short frontmatter description and a series of steps: a saved prompt-plus-procedure that Cascade can replay instead of you re-explaining the task each time.

Workflows are manual and specific to Cascade. Open a Cascade session and invoke this one with `/ahasend-email`; another agent mode will not run it, and Cascade will not select it automatically.

```markdown .windsurf/workflows/ahasend-email.md theme={null}
---
description: Add a transactional email send via the AhaSend SDK
---

# Add AhaSend email

1. If `lib/ahasend.ts` does not exist, create it: import `AhaSendClient`
   from `@ahasend/sdk` and export one client built from
   `AHASEND_API_KEY` and `AHASEND_ACCOUNT_ID`. Validate those variables
   and `AHASEND_DELIVERY_MODE` (`sandbox` or `live`) at startup. Module
   scope only.
2. In the target handler, import that client and call
   `client.messages.send({ from, recipients, subject, html_content,
   text_content, sandbox }, { idempotencyKey })`. Build a mode-prefixed
   idempotency key from a stable server-side business-event ID. Body
   fields are snake_case.
3. Filter `result.data` for entries with `status === "error"` and log
   how many recipients were rejected. Never read only `result.data[0]`.
4. Wrap the call in a try/catch for `AhaSendAPIError` and log
   only `err.status`, `err.code`, and `err.requestId`, never the whole
   error object or its message.
5. Await the send before completing the handler. If it should happen
   after the response, enqueue a durable job and send from its worker;
   do not start an unobserved promise.
6. Mock the AhaSend boundary in automated tests. Only with explicit user
   approval, run one integration send using a dedicated Sandbox-mode
   credential and confirm no recipient result has `status === "error"`.
```

Adding email to a new screen is now one invocation, and because the recipe encodes the same facts as your Rules file, every replay lands the integration the same way.

## Step 4: Prompt Cascade with Specifics

Whether you're invoking the Workflow or asking directly, name the file and the behavior:

* Weak: "add email to my app"
* Better: "In the signup handler, after the user is created, send a welcome email through AhaSend using `/ahasend-email`. Reuse the client from `lib/ahasend.ts`, use the server-side signup event ID for idempotency, and await the send. If response latency matters, enqueue a durable job and send from its worker."

Here is the code the Workflow is grounded against:

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

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"),
});

const mode = requireEnv("AHASEND_DELIVERY_MODE");
if (mode !== "sandbox" && mode !== "live") {
  throw new Error("AHASEND_DELIVERY_MODE must be sandbox or live");
}

export const deliveryMode = mode;
```

```ts routes/signup.ts theme={null}
import { AhaSendAPIError } from "@ahasend/sdk";
import { ahasend, deliveryMode } from "../lib/ahasend.js";

type SignupEvent = {
  id: string;
  email: string;
  name: string;
};

export async function sendWelcome(signupEvent: SignupEvent) {
  try {
    const result = await ahasend.messages.send(
      {
        from: { email: "hello@yourdomain.com", name: "Your App" },
        recipients: [{ email: signupEvent.email, name: signupEvent.name }],
        subject: "Welcome aboard",
        text_content: "Thanks for signing up.",
        html_content: "<p>Thanks for signing up.</p>",
        sandbox: deliveryMode === "sandbox",
      },
      {
        idempotencyKey: `${deliveryMode}-welcome-${signupEvent.id}`,
      },
    );

    const rejected = result.data.filter((entry) => entry.status === "error").length;
    const accepted = result.data.length - rejected;
    if (rejected > 0) {
      console.warn("AhaSend rejected recipients", { accepted, rejected });
      throw new Error("The welcome email was not accepted");
    }

    return { accepted, rejected };
  } catch (error) {
    if (error instanceof AhaSendAPIError) {
      console.error("AhaSend request failed", {
        status: error.status,
        code: error.code,
        requestId: error.requestId,
      });
      throw new Error("The welcome email request failed");
    }
    throw error;
  }
}
```

## Step 5: Test in Sandbox Mode

Cascade can run terminal commands, but a production-capable API key remains production-capable even when one request sets `sandbox: true`. Prefer mocked tests. If you explicitly approve a live integration check, use a dedicated domain-scoped credential configured in Sandbox mode. [Sandbox mode](https://ahasend.com/docs/send-api/sandbox.md) validates and processes the message, triggers relevant configured webhooks, consumes no email credits, and never delivers it.

```ts theme={null}
const result = await ahasend.messages.send({
  from: { email: "hello@yourdomain.com", name: "Your App" },
  recipients: [{ email: "test@example.com", name: "Ada" }],
  subject: "Welcome aboard",
  text_content: "Thanks for signing up.",
  sandbox: true,
  sandbox_result: "bounce", // rehearse the failure path
});
```

With explicit approval, ask Cascade to send once with the dedicated Sandbox-mode credential and confirm no recipient result has `status === "error"`. Swap `sandbox_result` to `"deliver"`, `"bounce"`, `"defer"`, `"fail"`, or `"suppress"` to trigger the applicable configured webhook event. A webhook test also needs a configured endpoint that AhaSend can reach. Keep delivery fail-closed through `AHASEND_DELIVERY_MODE`; do not infer live delivery from a generic environment name.

## Step 6: Read the Diff

Confirm these before you commit:

* **The real package.** The import must be `@ahasend/sdk`. If you see another package name, or raw `fetch` calls in a plain Node service, a guess slipped past the rule.
* **No open relay.** If Cascade added an HTTP route that sends, check that the recipient comes from the authenticated session or a server-side record rather than the request body, that the route is authenticated and rate-limited, and that the body size is bounded. A handler taking `to`, `subject`, and `html` from an unauthenticated caller is a spam relay on your verified domain.
* **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 in a batch.
* **The key comes from the environment.** An agent editing several files can drift a secret into the wrong one. Confirm the key is read from `process.env` and never written to anything git tracks.

> [!WARNING]
>
> If Cascade scaffolds frontend code, make sure the key never lands in a client-exposed variable (`NEXT_PUBLIC_`, `VITE_`, `PUBLIC_`, `NUXT_PUBLIC_`). Anything with those prefixes ships to the browser.

## 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] Cascade ignores the Rules file
>
> Confirm the file is under `.devin/rules/` in the current workspace and has valid `trigger` frontmatter. With `model_decision`, Cascade sees the description first and loads the rule when it decides that description is relevant. Make that description more specific, or change the trigger to `always_on` if the full rule must be included on every request.

> [accordion] The Workflow runs but skips the sandbox check
>
> Workflow steps are instructions, not a script. Make the last step an explicit, checkable assertion ("confirm no entry in result.data has status error before reporting done") rather than a vague "test it".

> [accordion] The generated code throws AhaSendConfigurationError on startup
>
> If Cascade put the client in frontend code, move the send to a server-side route. If it is already server-side, verify the required environment variables and account ID. The browser check is defense in depth, not a substitute for keeping credentials out of client bundles.

> [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, and see [the quickstart](https://ahasend.com/docs/quickstart.md) if you haven't added one yet.

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.