Docs
GentleMail is an inbox for your team and an API for your code, on the same mailboxes. Here's how to send from them, get told the moment mail arrives, and keep out what you don't want.
How it fits together
Your GentleMail is a Worker on your Cloudflare account. It serves domains you connect, and each domain holds as many mailboxes (addresses) as you create. Users sign in to the web app: admins see every mailbox, everyone else sees the mailboxes they've been given.
Programs use the same mailboxes through the HTTP API, with API keys, and hear about new mail through webhooks. Everything below lives under https://<your-gentlemail>/api/v1. Inside the app, Admin → API & webhooks has a reference for your own instance.
API keys
Create keys in Admin → API & webhooks. Each key has one or both scopes: send to send email, read to list and read messages, download attachments and move messages between folders. You can limit a key to one mailbox; otherwise it reaches all of them.
Keys start with gm_ and are shown once. GentleMail only stores a hash. Send the key on every request:
Authorization: Bearer gm_…
Send an email
POST /api/v1/send with a JSON body sends from one of your mailboxes. The message is stored in that mailbox's Sent folder, threaded with the conversation, and a message.sent webhook fires. Needs the send scope.
curl https://mail.yourco.com/api/v1/send \
-H "Authorization: Bearer $GENTLEMAIL_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "support@yourco.com",
"to": ["Ada Lovelace <ada@example.com>"],
"subject": "Your order has shipped",
"text": "It should arrive on Friday.",
"html": "<p>It should arrive on <b>Friday</b>.</p>"
}'| Field | Type | What it does |
|---|---|---|
| from | string | Address of a mailbox this key may use. Required. |
| to, cc, bcc | string | object | array | "a@b.c", "Name <a@b.c>", { "address", "name" }, or a list of those. |
| subject | string | Subject line. Defaults to Re: the original when replying. |
| text, html | string | At least one of them. With only HTML, a plain-text version is made for you. |
| attachments | array | Each item: { filename, content (base64), content_type? }. |
| reply_to_message_id | string | ID of a message in this mailbox. Threads the reply; to defaults to its sender. |
| quote | boolean | With a reply, quote the original below your text. |
| signature | boolean | Append the mailbox's signature. |
{ "id": "…", "thread_id": "…", "from": "support@yourco.com", "status": "sent" }Answering a message you got through a webhook takes one field: pass its data.id as reply_to_message_id, and leave to and subject out.
Read mail
All of these need the read scope, except the mailbox list, which any key can call.
| Field | Type | What it does |
|---|---|---|
| GET /mailboxes | The mailboxes this key can use. | |
| GET /messages | Newest first. Query: mailbox, folder (inbox, sent, archive, spam, trash, all), unread=true, since (ISO date or epoch ms), limit (1 to 100, default 25). | |
| GET /messages/:id | One message, with its text, its original HTML and its attachments. | |
| POST /messages/:id | Body { "action": … }: read, unread, star, unstar, archive, trash, spam or inbox. | |
| GET /attachments/:id | The file itself. |
html is the message exactly as received. Never render it in a browser without sanitising it first.
Webhooks
In Admin → API & webhooks, add a webhook with the URL that should receive events, the mailbox to listen to (or all of them) and the events: message.received, message.sent or both. You get a signing secret starting with whsec_. Set it up once: from then on, every matching message is delivered.
POST https://agents.yourco.com/mail
Content-Type: application/json
User-Agent: GentleMail-Webhooks/1
X-GentleMail-Event: message.received
X-GentleMail-Delivery: 6f1c2e4a-…
X-GentleMail-Timestamp: 1794580000
X-GentleMail-Signature: v1=5f2c…
{
"id": "6f1c2e4a-…",
"event": "message.received",
"created_at": "2026-11-13T09:46:02.000Z",
"data": {
"id": "…",
"thread_id": "…",
"mailbox": { "id": "…", "address": "invoices@yourco.com" },
"folder": "inbox",
"direction": "inbound",
"from": { "name": "Acme Billing", "address": "billing@acme.com" },
"to": [{ "name": "", "address": "invoices@yourco.com" }],
"cc": [],
"reply_to": null,
"subject": "Invoice INV-2041",
"date": "2026-11-13T09:46:00.000Z",
"snippet": "Please find the November invoice attached.",
"text": "Please find the November invoice attached.\n…",
"text_truncated": false,
"has_html": true,
"attachments": [{
"id": "…", "filename": "invoice-2041.pdf", "content_type": "application/pdf",
"size": 48213, "inline": false,
"url": "https://mail.yourco.com/api/v1/attachments/…"
}],
"url": "https://mail.yourco.com/api/v1/messages/…"
}
}- The text comes in the event itself (up to 200,000 characters), so most agents never need a second call. Attachments and the HTML are fetched with a
readkey from the URLs given. - Answer with a
2xxwithin 10 seconds. Redirects aren't followed. - Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours, then marked failed. Each webhook's page shows its delivery log, with a button to retry and one to send a test.
- A delivery can arrive more than once. Use the top-level
id(also inX-GentleMail-Delivery) to skip ones you've already handled. - Mail that lands in spam never triggers a webhook.
Verify a webhook
The signature is an HMAC-SHA256 of the timestamp, a dot and the raw body, keyed with your secret and written in hex. Check it on the raw body before parsing, and reject old timestamps to stop replays.
async function verify(request: Request, secret: string) {
const body = await request.text();
const ts = request.headers.get("X-GentleMail-Timestamp") ?? "";
const sig = request.headers.get("X-GentleMail-Signature") ?? "";
// Refuse anything older than five minutes.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return null;
const key = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"],
);
const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${ts}.${body}`));
const expected = "v1=" + [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
// Compare in constant time.
if (expected.length !== sig.length) return null;
let diff = 0;
for (let i = 0; i < sig.length; i++) diff |= expected.charCodeAt(i) ^ sig.charCodeAt(i);
return diff === 0 ? JSON.parse(body) : null;
}If a secret leaks, generate a new one on the webhook's page. The old one stops working straight away.
Recipes
Each recipe is a small Cloudflare Worker (any HTTP server works the same way) that receives a webhook. They all start by checking the signature with the verify function above, and keep their secrets in Worker secrets: WEBHOOK_SECRET for the webhook and GENTLEMAIL_KEY for the API.
export default {
async fetch(request: Request, env: Env) {
const event = await verify(request, env.WEBHOOK_SECRET);
if (!event) return new Response("Bad signature", { status: 401 });
if (event.event !== "message.received") return new Response("Ignored");
await handle(event.data, env); // one of the recipes below
return new Response("OK");
},
};Invoices that pay themselves
A webhook on invoices@, and a key with the read scope limited to that mailbox. Each PDF is saved to R2 and queued for your payment pipeline, where you read the amount and wait for approval.
async function handle(mail, env) {
for (const file of mail.attachments) {
if (file.content_type !== "application/pdf") continue;
const pdf = await fetch(file.url, {
headers: { Authorization: `Bearer ${env.GENTLEMAIL_KEY}` },
});
const key = `${mail.id}/${file.filename}`;
await env.INVOICE_FILES.put(key, await pdf.arrayBuffer());
await env.PAYMENTS.send({ key, from: mail.from.address, subject: mail.subject });
}
}Support that answers in minutes
A webhook on support@ that posts to a Slack incoming webhook. Any chat with incoming webhooks (Discord, Teams, Mattermost) works the same way.
async function handle(mail, env) {
await fetch(env.SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: `New email to ${mail.mailbox.address} from ${mail.from.name || mail.from.address}\n*${mail.subject}*\n${mail.snippet}`,
}),
});
}Your inbox, in your pocket
One webhook on all mailboxes, sending each message to Telegram. Create a bot with BotFather and put its token and your chat ID in Worker secrets. To get a real summary instead of the first lines, pass mail.text to a model first, for example with Workers AI.
async function handle(mail, env) {
await fetch(`https://api.telegram.org/bot${env.TELEGRAM_TOKEN}/sendMessage`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: env.TELEGRAM_CHAT_ID,
text: `${mail.mailbox.address}\nFrom: ${mail.from.name || mail.from.address}\n${mail.subject}\n\n${mail.snippet}`,
}),
});
}Out of office, handled
A webhook on the person's mailbox for message.received only, and a key with the send scope limited to that mailbox. A KV namespace remembers who already got an answer, so each person is told once and automated senders are never answered.
async function handle(mail, env) {
const sender = mail.from.address.toLowerCase();
if (/^(no-?reply|mailer-daemon|postmaster)@/.test(sender)) return;
if (await env.REPLIED.get(sender)) return;
await fetch("https://mail.yourco.com/api/v1/send", {
method: "POST",
headers: {
Authorization: `Bearer ${env.GENTLEMAIL_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: mail.mailbox.address,
reply_to_message_id: mail.id,
text: "Thanks for your message. I'm away until Monday. For anything urgent, write to team@yourco.com.",
}),
});
await env.REPLIED.put(sender, "1", { expirationTtl: 7 * 24 * 3600 });
}Turn the webhook off in Admin when they're back. Built-in auto-replies are on the roadmap.
Blocking and spam
Unwanted mail can be stopped at four levels, from the widest to the narrowest:
- Cloudflare, before GentleMail. Email Routing refuses mail that fails SPF and DKIM, fails the sender's DMARC policy, or comes from blocklisted servers. In the dashboard, a routing rule for one address with the action Drop discards its mail before it reaches your Worker.
- A whole domain or mailbox. Turn it off in Admin and its mail is refused. Addresses that don't exist are refused too.
- A sender. Mark a message as spam and its sender is blocked for that mailbox. Their next messages go straight to spam. Moving a message out of Spam does the opposite and trusts its sender. Mailbox settings lists both, and removing a sender there undoes it.
- A spam score. Set
SPAM_SCORE_THRESHOLDinwrangler.jsoncto send mail whose Cloudflare spam score reaches it to the Spam folder. It's off by default.
Spam never triggers webhooks and is deleted after 30 days.
Errors
Errors use HTTP status codes and a body your code can switch on:
{ "error": { "code": "mailbox_not_allowed", "message": "This key can't send from sales@yourco.com." } }| Field | Type | What it does |
|---|---|---|
| 401 | unauthorized | No key, or an unknown one. |
| 403 | forbidden, mailbox_not_allowed | The key lacks the scope, or can't use that mailbox. |
| 404 | not_found, original_not_found | No such message, attachment or route. |
| 422 | invalid_…, missing_body, mailbox_disabled | Something in the request needs fixing. The message says what. |