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 agentisendAPI routes run on demand, so the project needs a server adapter such as @astrojs/node.
Environment
| Variable | Required | What it is |
|---|---|---|
AGENTISEND_API_KEY | yes | A key with sending_access. |
MAIL_FROM | yes | The From address, on a domain you have verified. |
AGENTISEND_BASE_URL | no | Defaults 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;
}
};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.