Integration
Send email from Astro with SNDR.
Send transactional email from Astro — API routes, Actions, server endpoints, and webhook receivers. Works with the Node, Vercel, Cloudflare, and Netlify adapters.
Astro is increasingly the framework of choice for content-heavy sites that need a small amount of dynamic behavior. Use a Node-compatible server adapter for the SDK. Cloudflare requires nodejs_compat; use the HTTP API on Edge targets without Node crypto. The recipes below show the four patterns: send from an API route, send from a typed Astro Action, verify an incoming webhook, and a small OTP flow.
Setup
Install + configure
pnpm add @rkiza/sndr
# or
npm install @rkiza/sndr
# or
yarn add @rkiza/sndr# .env (Astro reads these via import.meta.env when prefixed with PUBLIC_,
# and via process.env on server-only code — never expose SNDR_API_KEY to
# the client.)
SNDR_API_KEY=sndr_live_x9k2...
SNDR_WEBHOOK_SECRET=... # only if you receive webhooksRecipes
The patterns you actually need.
Enable SSR + add an adapter
Astro is static by default — to run server code you need output: "server" (full SSR) or "hybrid" (per-page opt-in). Add the right adapter for your deploy target.
// astro.config.mjs import { defineConfig } from 'astro/config'; import vercel from '@astrojs/vercel/serverless'; // or: import node from '@astrojs/node'; // or: import cloudflare from '@astrojs/cloudflare'; export default defineConfig({ output: 'hybrid', adapter: vercel(), });Send from an API route
Export a POST function from pages/api/<route>.ts. The endpoint context gives you the standard Web Request and a typed environment helper.
// src/pages/api/welcome.ts import type { APIRoute } from 'astro'; import { Sndr, SndrError } from '@rkiza/sndr'; const sndr = new Sndr(import.meta.env.SNDR_API_KEY); export const POST: APIRoute = async ({ request }) => { const { email, name } = (await request.json()) as { email: string; name: string }; try { const { id } = await sndr.emails.send({ from: 'hello@yourdomain.com', to: email, subject: `Welcome, ${name}`, html: `<p>Thanks for joining us, ${name}.</p>`, }); return new Response(JSON.stringify({ id }), { status: 200, headers: { 'content-type': 'application/json' }, }); } catch (err) { if (err instanceof SndrError) { return new Response( JSON.stringify({ error: { code: err.code, message: err.message } }), { status: (err.status >= 400 && err.status <= 599 ? err.status : 502), headers: { 'content-type': 'application/json' } }, ); } throw err; } };Send from a typed Astro Action
Astro Actions (added in 4.15+) are typed server functions you can call from any component or page. Define the input schema with Zod once and Astro generates the client wrapper.
// src/actions/index.ts import { defineAction } from 'astro:actions'; import { z } from 'astro:schema'; import { Sndr } from '@rkiza/sndr'; const sndr = new Sndr(import.meta.env.SNDR_API_KEY); export const server = { sendContact: defineAction({ accept: 'form', input: z.object({ name: z.string().min(2).max(120), email: z.string().email(), message: z.string().min(10).max(8000), }), handler: async ({ name, email, message }) => { await sndr.emails.send({ from: 'hello@yourdomain.com', to: 'team@yourdomain.com', reply_to: email, subject: `New contact from ${name}`, text: message, }); return { ok: true }; }, }), };Verify an incoming webhook
API routes give you the standard Request, so reading the raw bytes is request.arrayBuffer(). Verify the HMAC before parsing the JSON.
// src/pages/api/webhooks/sndr.ts import type { APIRoute } from 'astro'; import { verifyWebhookSignature } from '@rkiza/sndr'; import type { WebhookEventPayload } from '@rkiza/sndr'; export const POST: APIRoute = async ({ request }) => { const raw = Buffer.from(await request.arrayBuffer()); const ok = verifyWebhookSignature( raw, request.headers.get('x-sndr-signature') ?? '', import.meta.env.SNDR_WEBHOOK_SECRET, ); if (!ok) return new Response('bad signature', { status: 400 }); const event = JSON.parse(raw.toString('utf8')) as WebhookEventPayload; switch (event.type) { case 'email.delivered': // event.data.email_id, event.data.to: string[] break; case 'email.bounced': // event.data.email_id, event.data.error break; case 'email.unsubscribed': // event.data.addresses: string[] break; } return new Response('ok'); };OTP / 6-digit-code flow
Generate a 6-digit code, persist it with a TTL, send it via SNDR, and validate on the next request. The SDK's auto idempotency key prevents double-send on retries.
// src/pages/api/otp.ts import type { APIRoute } from 'astro'; import { Sndr } from '@rkiza/sndr'; const sndr = new Sndr(import.meta.env.SNDR_API_KEY); export const POST: APIRoute = async ({ request }) => { const { email } = (await request.json()) as { email: string }; const code = String(Math.floor(100000 + Math.random() * 900000)); // ...persist (email, code, expiresAt) with 10-min TTL... await sndr.emails.send({ from: 'auth@yourdomain.com', to: email, subject: `Your sign-in code: ${code}`, text: `Your code is ${code}. It expires in 10 minutes.`, }); return new Response('check your email'); };
Astro FAQ
Why do I need output: "server" or "hybrid"?
Will the SDK work on Cloudflare Pages / Workers?
How do I keep the API key out of the client bundle?
How do I handle webhook idempotency from my side?
Send your first Astro email.
3,000 emails a month on the Free plan. No credit card needed.