Skip to content
couryo

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.

View as Markdown

An Express server that sends the welcome email on sign-up and receives delivery events by webhook, with the Node SDK.

1. Install#

Terminal
npm install express @couryo/node
.env
COURYO_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#

server.js
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:

server.js
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#

server.js
// 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:

mailer.js
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.