# Send email with Fastify

> Send email from Fastify with Email Fast: a typed POST route using @email-fast/nodejs, schema validation, idempotency, and a plugin that shares one client.

Canonical: https://emailfast.dev/send-email-with/fastify

Fastify's schema validation is the right front door for email: declare the body shape
once and malformed requests never reach your send call. Handlers are async by
default — return the JSON, throw on failure — so the send is three honest lines with
`@email-fast/nodejs`.

```bash
npm install fastify @email-fast/nodejs
```

```js
// server.mjs
import Fastify from "fastify";
import { EmailFast } from "@email-fast/nodejs";

const app = Fastify({ logger: true });
const ef = new EmailFast({ apiKey: process.env.EMAILFAST_API_KEY, baseUrl: "https://api.emailfast.dev" }); // ef_sandbox_… while testing

app.post(
  "/invite",
  {
    schema: {
      body: {
        type: "object",
        required: ["email", "team"],
        properties: {
          email: { type: "string" },
          team: { type: "string" },
        },
      },
    },
  },
  async (req, reply) => {
    const { email, team } = req.body;
    const { id } = await ef.send({
      to: email,
      subject: "You're invited",
      html: "<p>Join {{team}} here.</p>",
      data: { team },
      idempotency_key: `invite-${team}-${email}`, // a retried request can't double-invite
    });
    reply.code(202);
    return { id };
  },
);

app.setErrorHandler((err, req, reply) => {
  req.log.error(err);
  reply.code(err.validation ? 400 : 502).send({ error: err.message });
});

await app.listen({ port: 3000 });
```

a 202 from the API means the send is committed to a durable, partitioned outbox before we answer — a crash can't lose it, and a retry with the same idempotency key can't double-send.

## Gotchas

- `format: "email"` in a JSON schema is silently ignored unless you register `ajv-formats` — Fastify's default Ajv build skips string formats. Validate presence and type locally; the API rejects invalid addresses with a clear error body.
- Whatever an async handler returns is the reply; a thrown error becomes a 500 unless `setErrorHandler` maps it. Route send failures to 502, validation failures to 400.
- In larger apps, register the client once in a plugin and `app.decorate("emails", ef)` so every route shares one instance.
- Build idempotency keys from route data (`invite-${team}-${email}`) so retries collapse instead of duplicating.

## Next

- [Full Node.js quickstart](/docs/quickstart-node) — timelines, webhooks, batch
- [The email API](/features/email-api) — batch, scheduling, suppressions
- [Sending from Express](/send-email-with/express)
- [All docs](/docs)
