Developer guideIntegrations
Send email from Next.js
Send transactional email with Couryo from Next.js (App Router), with a Server Action, a Route Handler, React Email and a signed webhook.
This guide uses the Next.js App Router and the Node SDK. Everything runs on the server: the API key never reaches the browser.
1. Install and set the key#
npm install @couryo/nodeCOURYO_API_KEY=ck_test_...
COURYO_WEBHOOK_SECRET=whsec_...Start with a ck_test_ key: nothing is delivered and every email shows up in the dashboard (test mode). Switch to ck_live_ once your domain is verified.
Create the client once, on the server only. Lazy creation avoids an error in next build when the variable is not set in the build environment:
import "server-only";
import { Couryo } from "@couryo/node";
let client: Couryo | undefined;
export function couryo(): Couryo {
client ??= new Couryo({ language: "en" }); // reads COURYO_API_KEY
return client;
}The SDK uses Node modules (
node:crypto). Keep the routes that send email on the default Node.js runtime, not Edge.
2. Server Action (contact form)#
"use server";
import { CouryoError } from "@couryo/node";
import { couryo } from "@/lib/couryo";
export type State = { ok: boolean; error?: string };
export async function sendContact(_prev: State, form: FormData): Promise<State> {
const email = String(form.get("email") ?? "").trim();
const message = String(form.get("message") ?? "").trim();
if (!email || !message) return { ok: false, error: "Fill in your email and message." };
try {
await couryo().emails.send({
from: "Example Site <contact@example.com>",
to: "team@example.com",
reply_to: email,
subject: `Website contact: ${email}`,
text: message,
});
return { ok: true };
} catch (e) {
// Couryo's message already says what to fix.
if (e instanceof CouryoError) return { ok: false, error: e.message };
throw e;
}
}"use client";
import { useActionState } from "react";
import { sendContact, type State } from "./actions";
const initial: State = { ok: false };
export default function Contact() {
const [state, action, pending] = useActionState(sendContact, initial);
return (
<form action={action}>
<input name="email" type="email" required placeholder="Your email" />
<textarea name="message" required placeholder="Message" />
<button disabled={pending}>{pending ? "Sending..." : "Send"}</button>
{state.ok && <p>Got your message.</p>}
{state.error && <p role="alert">{state.error}</p>}
</form>
);
}3. Route Handler (email triggered by your backend)#
When the same event can arrive twice (a repeated payment webhook, for example), pass an idempotency key that identifies the event. The second call gets the same response and nothing goes out twice.
import { couryo } from "@/lib/couryo";
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const order = await findOrder(id); // your function
const email = await couryo().emails.send(
{
from: "Example Store <orders@example.com>",
to: order.customer.email,
subject: `Order ${order.number} confirmed`,
html: `<p>Hi ${order.customer.name}, we got your order ${order.number}.</p>`,
text: `Hi ${order.customer.name}, we got your order ${order.number}.`,
tags: { kind: "order" },
metadata: { order_id: order.id },
},
{ idempotencyKey: `order-${order.id}-confirmation` },
);
return Response.json({ email_id: email.id, status: email.status });
}The 202 response only comes once the email is queued. If something blocks the send (unverified domain, daily limit, suppressed recipient), the SDK throws CouryoError with a stable code.
4. Templates with React Email#
React Email works as is: render the component to HTML and text and send both.
import { render } from "@react-email/render";
import Welcome from "@/emails/welcome"; // your React Email component
import { couryo } from "@/lib/couryo";
export async function POST(req: Request) {
const { email, name } = await req.json();
const html = await render(<Welcome name={name} />);
const text = await render(<Welcome name={name} />, { plainText: true });
await couryo().emails.send({ from: "Example <hello@example.com>", to: email, subject: `Welcome, ${name}!`, html, text });
return new Response(null, { status: 204 });
}Rather edit in the dashboard? Use a saved template: template: "welcome" and variables: { name } instead of html and text.
5. Receive webhooks#
Create the webhook in the dashboard (or through the API) pointing to https://your-site.com/api/webhooks/couryo and keep the whsec_ secret. Read the body as text; do not parse it as JSON before verifying:
import { verifyWebhook, WebhookVerificationError } from "@couryo/node";
export async function POST(req: Request) {
const body = await req.text();
try {
const event = verifyWebhook({ secret: process.env.COURYO_WEBHOOK_SECRET!, headers: req.headers, body });
if (event.type === "bounced") {
// event.data.recipient, event.data.smtp_code, event.data.explanation
}
return new Response(null, { status: 204 });
} catch (e) {
if (e instanceof WebhookVerificationError) return new Response("invalid signature", { status: 400 });
throw e;
}
}Any 2xx confirms the delivery; without one, Couryo retries for about 3 days. Details in Webhooks.
Without the SDK#
On any runtime with fetch (Edge included), the API is a single call:
const res = await fetch("https://api.couryo.com/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.COURYO_API_KEY}`,
"Content-Type": "application/json",
"Accept-Language": "en",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({ from: "Example <hello@example.com>", to: "ana@example.com", subject: "Hello", text: "Sent with Couryo." }),
});
if (!res.ok) throw new Error((await res.json()).error.message);Common problems#
| Symptom | What to do |
|---|---|
domain_not_verified |
the from domain is not verified in this project yet: see Domains and DNS |
sandbox_recipient |
the account is on level 0 and only sends to the account's own addresses until the first domain is verified |
Couryo: missing API key during build |
create the client inside a function (as in lib/couryo.ts) and set COURYO_API_KEY in production |
| Webhook signature always invalid | the body was read with req.json() first; use req.text() |