Browse the docs

Frameworks and agents · Updated 2026-09-28

Send email from Astro

An Astro API route that confirms a waitlist sign-up from a plain form post, refusing a malformed address before the API is called.

To send email from an Astro site, add an API route whose POST handler reads the form, calls AgentiSend and returns the message id. The route below confirms a waitlist sign-up: it refuses a malformed address before the API is called, and sends once per address however many times the form is submitted. It is executed against a real API on every build of this site's repository, called with a real form post: one send, and one malformed address refused with nothing sent.

Install

pnpm add agentisend

API routes run on demand, so the project needs a server adapter such as @astrojs/node.

Environment

VariableRequiredWhat it is
AGENTISEND_API_KEYyesA key with sending_access.
MAIL_FROMyesThe From address, on a domain you have verified.
AGENTISEND_BASE_URLnoDefaults to https://api.agentisend.com.

The route

Save it as src/pages/api/waitlist.ts.

/**
 * Astro — `src/pages/api/waitlist.ts`, an API route that confirms a waitlist
 * sign-up.
 *
 * The route runs on the server, so the API key never reaches the browser.
 * `prerender = false` keeps it on demand when the rest of the site is static.
 *
 * In your own project the handler is typed with Astro's own export:
 * `import type { APIRoute } from 'astro'` and `export const POST: APIRoute = …`.
 * It is written against the one field of the context it reads here, so this
 * directory typechecks without Astro installed.
 */
import { AgentiSend, AgentiSendError } from 'agentisend';

const agentisend = new AgentiSend();

function mailFrom(): string {
  const from = process.env.MAIL_FROM;
  if (!from) throw new Error('Set MAIL_FROM to an address on a domain you have verified.');
  return from;
}

/** Enough to refuse an obvious typo here; the API checks the address properly. */
const ADDRESS = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export const prerender = false;

export const POST = async ({ request }: { request: Request }): Promise<Response> => {
  const form = await request.formData();
  const email = String(form.get('email') ?? '').trim();
  if (!ADDRESS.test(email)) {
    return Response.json({ error: 'Enter an email address.' }, { status: 400 });
  }

  try {
    const { id } = await agentisend.emails.send(
      {
        from: mailFrom(),
        to: email,
        subject: 'You are on the waitlist',
        text: 'Thanks for signing up. We will write once, when your invite is ready.',
      },
      // A second submit of the same form replays the first send.
      { idempotencyKey: `waitlist/${email}` },
    );
    return Response.json({ id });
  } catch (err) {
    if (err instanceof AgentiSendError) {
      return Response.json({ code: err.code, fix: err.fix }, { status: err.status });
    }
    throw err;
  }
};
examples/astro/waitlist.ts, executed against the live API on every build

In your project, type the handler with Astro's own export:

import type { APIRoute } from 'astro';

export const POST: APIRoute = async ({ request }) => {
  /* the body above */
};

The form

<form method="POST" action="/api/waitlist">
  <input type="email" name="email" required />
  <button type="submit">Join the waitlist</button>
</form>

The key and its budget

Give the site a key of its own rather than the one you signed in with. POST /api-keys with sending_access mints a key that can send and read what it sent, and nothing else. PATCH /limits/keys/{id} puts a ceiling on it with budget_per_period and period, so a form that is flooded stops at a number you chose: past it a send is refused with agent_budget_exceeded, and the route answers with that code and its fix.

The idempotency key

Every send carries Idempotency-Key: waitlist/<recipient>, derived from the sign-up and never from the moment. A second submit of the same address, or a retry after a timeout, replays the first send instead of mailing the person twice.

Next