Frameworks and agents · Updated 2026-09-28
Send email from Fastify
A Fastify POST route whose JSON schema refuses a bad address before the handler runs, sending one invoice notice per invoice with an idempotency key.
To send email from a Fastify app, add one POST route that calls AgentiSend and returns the message id. In the route below, Fastify's JSON schema refuses a body without a well-formed address before the handler runs, so a bad request never reaches the API; a good one sends the invoice notice once per invoice. It is executed against a real API on every build of this site's repository: one send, and one malformed address refused with nothing sent.
Install
pnpm add fastify agentisendEnvironment
| 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
/**
* Fastify — a POST route that sends an invoice notice.
*
* The route's JSON schema checks the body before the handler runs, so a
* request without a well-formed address is answered by Fastify and never
* reaches the API. The app is exported without calling `listen`, so it can be
* registered, tested with `app.inject`, or started from your own entry point.
*/
import Fastify, { type FastifyInstance } from 'fastify';
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;
}
interface SendBody {
email: string;
invoiceId: string;
}
export const app: FastifyInstance = Fastify();
app.post<{ Body: SendBody }>(
'/send',
{
schema: {
body: {
type: 'object',
required: ['email', 'invoiceId'],
properties: {
email: { type: 'string', format: 'email' },
invoiceId: { type: 'string', pattern: '^[A-Za-z0-9_-]{1,64}$' },
},
},
},
},
async (request, reply) => {
const { email, invoiceId } = request.body;
try {
const { id } = await agentisend.emails.send(
{
from: mailFrom(),
to: email,
subject: `Invoice ${invoiceId}`,
text: `Invoice ${invoiceId} is ready in your account.`,
},
// One invoice, one email: a retried request replays the first send.
{ idempotencyKey: `invoice/${invoiceId}/${email}` },
);
return { id };
} catch (err) {
if (err instanceof AgentiSendError) {
// `fix` names the call that repairs the request.
return reply.code(err.status).send({ code: err.code, fix: err.fix });
}
throw err;
}
},
);
export default app;Start it
import app from './server';
await app.listen({ port: 3000 });curl -X POST localhost:3000/send \
-H 'content-type: application/json' \
-d '{"email":"you@example.com","invoiceId":"INV-1042"}'The key and its budget
Give the server 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; past the ceiling a send is refused with agent_budget_exceeded, and the route answers with that code and its fix instead of retrying.
The idempotency key
Every send carries Idempotency-Key: invoice/<invoiceId>/<recipient>, derived from the invoice and never from the moment. A client that retries after a timeout gets the first send's id back instead of mailing the same invoice twice.
What comes back when it goes wrong
Every refused request carries code, message, fix and docs_url. The route answers with the same status and forwards code and fix. The error catalogue lists every code.