Browse the docs

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 agentisend

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

/**
 * 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;
examples/fastify/server.ts, executed against the live API on every build

Start it

import app from './server';

await app.listen({ port: 3000 });
index.ts
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.

Next