Send email from Cloudflare Workers with Shipmail
Send email from a Cloudflare Worker with the shipmail SDK, which calls the Shipmail API over fetch. Keep the API key in a Worker secret, and send with ctx.waitUntil so the response does not wait for the email.
Last updated
Before you start
- A verified sending subdomain, such as
notify.example.com, with an active sender. See Transactional email. - An API key with send access to the sender's mailbox.
- The sender address, such as
hello@notify.example.com. Use the bare address. Shipmail adds the sender name.
Install and configure
Add the TypeScript SDK to your Worker. Cloudflare recommends wrangler.jsonc for new projects. The SDK checks webhook signatures with Node's crypto module, so the Worker needs Node.js compatibility. It is on by default from compatibility date 2026-08-04, and the nodejs_compat flag keeps it on with an older date.
npm install shipmail// wrangler.jsonc
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "acme-email",
"main": "src/index.ts",
"compatibility_date": "2026-10-30",
"compatibility_flags": ["nodejs_compat"],
"vars": {
"EMAIL_FROM": "hello@notify.example.com"
}
}Store the API key as a secret
Wrangler reads .dev.vars during local development. For the deployed Worker, wrangler secret put stores the key and deploys a new version at once. Never put the key in vars: those values are not encrypted.
# .dev.vars (local only, keep it out of Git)
SHIPMAIL_API_KEY=sm_test_...npx wrangler secret put SHIPMAIL_API_KEYSend from the fetch handler
Pass fetch to the client through a small wrapper, as below. Without it, the Workers runtime refuses the call with an Illegal invocation error. ctx.waitUntil keeps the Worker running for up to 30 seconds after the response, which is enough for one send. Await sendEmail instead when the response depends on the result. The SDK throws if Shipmail refuses the send.
// src/index.ts
import { ShipmailClient } from "shipmail";
type Env = {
readonly SHIPMAIL_API_KEY: string;
readonly EMAIL_FROM: string;
};
function sendEmail(env: Env, to: string, subject: string, text: string) {
const shipmail = new ShipmailClient({
apiKey: env.SHIPMAIL_API_KEY,
// Call fetch through a wrapper. Workers rejects a fetch called as another object's method.
fetch: (input, init) => fetch(input, init),
});
return shipmail.messages.send({ from: env.EMAIL_FROM, to: [to], subject, text });
}
export default {
async fetch(request, env, ctx): Promise<Response> {
const url = new URL(request.url);
if (request.method === "POST" && url.pathname === "/signup") {
const email = String((await request.formData()).get("email") ?? "");
// Create the account here.
ctx.waitUntil(
sendEmail(env, email, "Welcome to Acme", "Thanks for signing up.").catch((error) =>
console.error("Welcome email not sent", error),
),
);
return Response.json({ ok: true });
}
return new Response("Not found", { status: 404 });
},
} satisfies ExportedHandler<Env>;Test without sending
Use a sm_test_ key in .dev.vars while you build. Shipmail accepts the request, delivers nothing and lists the send under Test in Activity. See Sandbox mode.
Receive replies
Replies to your sender address land in its Shipmail mailbox. To act on them in the Worker, add a webhook under Developer, then Webhooks, or with the Webhooks API.
| Setting | Value |
|---|---|
| Endpoint URL | https://<your worker>/webhooks/shipmail |
| Events | message.received |
| Signing secret | Shown once after you save. Store it as SHIPMAIL_WEBHOOK_SECRET. |
Shipmail can deliver the same event more than once, so the Worker records each event_id in a D1 table after it processes the event, and skips the ones already recorded. Create the table and the secret, then bind the database:
npx wrangler d1 create acme-email
npx wrangler d1 execute acme-email --remote \
--command="CREATE TABLE shipmail_events (event_id TEXT PRIMARY KEY)"
npx wrangler secret put SHIPMAIL_WEBHOOK_SECRET// wrangler.jsonc, next to "vars"
"d1_databases": [
{
"binding": "DB",
"database_name": "acme-email",
"database_id": "<the id from wrangler d1 create>"
}
]- Read the body with
request.text()and verify it before parsing. A bad signature or a timestamp older than five minutes gets a401. - Answer with a
2xxwithin 10 seconds. Shipmail retries a failed delivery and disables the endpoint after five failures in a row. The Worker handles the event before it answers, not inctx.waitUntil, so a failure returns an error and gets a retry.
// src/index.ts
import { ShipmailClient, verifyWebhook, type WebhookEvent } from "shipmail";
type Env = {
readonly SHIPMAIL_API_KEY: string;
readonly SHIPMAIL_WEBHOOK_SECRET: string;
readonly EMAIL_FROM: string;
readonly DB: D1Database;
};
async function handleWebhook(request: Request, env: Env): Promise<Response> {
// Verify the raw body, before any JSON parsing.
const body = await request.text();
let event: WebhookEvent;
try {
event = await verifyWebhook(body, request.headers, env.SHIPMAIL_WEBHOOK_SECRET);
} catch {
return new Response("Invalid signature", { status: 401 });
}
// A retry carries the same event_id. Skip one that was already processed.
const seen = await env.DB.prepare("SELECT 1 FROM shipmail_events WHERE event_id = ?")
.bind(event.event_id)
.first();
if (seen) return new Response(null, { status: 204 });
// Handle it before answering: a thrown error returns 500 and Shipmail retries.
if (event.event_type === "message.received") {
await handleReply(env, event);
}
await env.DB.prepare(
"INSERT INTO shipmail_events (event_id) VALUES (?) ON CONFLICT (event_id) DO NOTHING",
)
.bind(event.event_id)
.run();
return new Response(null, { status: 204 });
}
async function handleReply(env: Env, event: WebhookEvent): Promise<void> {
// Your code: read the conversation, answer it, or hand it to an agent.
console.log("New reply", event.event_id);
}
// In fetch(), before the /signup route:
if (request.method === "POST" && url.pathname === "/webhooks/shipmail") {
return handleWebhook(request, env);
}Signature details are in Webhook verification. To read the conversation and reply to it, see Receive email in an AI agent. Cloudflare Email Routing can also run a Worker on mail sent to a domain on Cloudflare. This guide does not use it: the reply goes to your Shipmail mailbox, and Shipmail calls the Worker.