Developer guideIntegrations
Node.js with Express
Send email with Couryo from a Node.js Express API, with idempotency, error handling and a signed webhook read from the raw body.
An Express server that sends the welcome email on sign-up and receives delivery events by webhook, with the Node SDK.
1. Install#
npm install express @couryo/nodeCOURYO_API_KEY=ck_test_...
COURYO_WEBHOOK_SECRET=whsec_...Run it with node --env-file=.env server.js (Node 20.6 or newer reads .env on its own).
2. Send on sign-up#
import express from "express";
import { Couryo, CouryoError, verifyWebhook, WebhookVerificationError } from "@couryo/node";
const app = express();
const couryo = new Couryo({ language: "en" }); // reads COURYO_API_KEY
app.post("/signup", express.json(), async (req, res) => {
const { email, name } = req.body;
const user = await createUser({ email, name }); // your function
try {
const sent = await couryo.emails.send(
{
from: "Example <hello@example.com>",
to: email,
subject: `Welcome, ${name}!`,
html: `<p>Hi ${name}, your account is ready.</p>`,
text: `Hi ${name}, your account is ready.`,
tags: { kind: "welcome" },
},
// If this route runs twice for the same user, the email goes out once.
{ idempotencyKey: `welcome-${user.id}` },
);
res.status(201).json({ user: user.id, email_id: sent.id });
} catch (e) {
if (e instanceof CouryoError) {
// e.code is stable: domain_not_verified, daily_limit_reached, recipient_suppressed...
console.error(e.code, e.message, e.requestId);
return res.status(201).json({ user: user.id, warning: "account created, email not sent" });
}
throw e;
}
});The SDK already retries network errors, 5xx and rate_limited, always with the same Idempotency-Key. What reaches the catch is what retrying will not fix.
3. Receive events by webhook#
The signature covers the exact body. Use express.raw on this route, never express.json:
app.post("/webhooks/couryo", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = verifyWebhook({ secret: process.env.COURYO_WEBHOOK_SECRET, headers: req.headers, body: req.body });
} catch (e) {
if (e instanceof WebhookVerificationError) return res.status(400).send("invalid signature");
throw e;
}
switch (event.type) {
case "delivered":
break;
case "bounced":
case "complained":
// Couryo already suppressed the address when it should. Flag it in your database if you like:
// event.data.recipient, event.data.explanation
break;
}
res.sendStatus(204);
});
app.listen(3000);If you have a global express.json(), register it after this route, or keep it only on the routes that need it (as above). Answer fast: any 2xx confirms; slow work goes to your own queue.
4. Batch, templates and scheduling#
// Up to 100 emails in one call, one result per item, in the same order.
const { data } = await couryo.emails.batch(
customers.map((c) => ({ from: "Store <hello@example.com>", to: c.email, template: "delivery-notice", variables: { name: c.name } })),
);
// Schedule for one hour from now (up to 30 days).
await couryo.emails.send({ from: "Store <hello@example.com>", to: "ana@example.com", subject: "Reminder", text: "...", scheduled_at: new Date(Date.now() + 3600_000).toISOString() });Over SMTP (Nodemailer)#
If the project already uses Nodemailer, change only the transport:
import nodemailer from "nodemailer";
export const transport = nodemailer.createTransport({
host: "smtp.couryo.com",
port: 587, // STARTTLS; or 465 with secure: true
secure: false,
requireTLS: true,
auth: { user: "couryo", pass: process.env.COURYO_API_KEY },
});Both paths produce the same timeline and the same webhooks. See SMTP for the reply codes.