The API (/v1)
Three things your application can do:
* Send one email — a password reset, a receipt, a confirmation code — through the provider account you already connected here. * Sign somebody up to a mailing list from a form on your own website, with the confirmation email and the double opt-in handled for you. * Draft a campaign — fill in the content of one of your templates from your own app and leave somebody a draft to read and send.
They use the same kind of key, but never the same key: a key does one of those things, and the reason is in Permissions.
It is deliberately shaped like Resend's API, so an application already written against Resend moves by changing the base URL and the key. Nothing else.
This page is published at https://cf-email.jderrick.app/docs/api — hand that URL to whoever is integrating; it needs no account to read.
Base URL: https://cf-email.jderrick.app
What this is for, and what it is not for
This endpoint sends one message to one person, because that person did something. A reset link, an invoice, a login code.
It is not a way to send campaigns. There is no batch endpoint, to takes one address, and the per-key rate limit is sized for real user actions rather than a mailout. Bulk mail goes through a campaign, which is where the unsubscribe footer, the postal address and the list management live.
That difference is not a policy we ask you to respect — it is how the code is built. Campaign mail physically cannot leave without an unsubscribe link, and this path physically cannot reach the campaign renderer.
Authentication
Every request carries a bearer token:
Authorization: Bearer cfe_your_key_here
Mint one at API keys (in the navigation drawer). Three things about keys:
- A key is shown once, when you create it. We store only a hash, so we cannot show it to you again — not even if you ask. Lose the secret and you re-roll the key: it keeps its name, its domain and its message history, and gets a new secret shown once. The old secret stops working immediately — which also makes re-roll the right response to a suspected leak.
- A key is pinned to one sending domain. It can only send
froman address on that domain, and the provider account it relays through is whichever one that domain is verified with. To move an app from one provider to another, verify its domain on the new account and repoint the key; the app changes nothing. - Revoking is immediate. Already-sent messages keep reporting their bounces.
Permissions
When you create a key you choose one permission, and a key never has more than one:
| Permission | Can do | Cannot do |
|---|---|---|
| Send email | POST /v1/emails, GET /v1/emails/:id | Touch contacts, lists or campaigns |
| Send email, pinned to one recipient | The same, to one fixed address | Mail anybody else |
| Add contacts to a list | POST /v1/contacts | Send anything at all |
| Draft a campaign | POST /v1/campaigns, GET /v1/campaigns/:id | Send anything at all, including the drafts it creates |
A key pinned to one recipient
When you create a sending key you can name one address it may ever send to. The key then supplies to itself and the body may not contain one — sending to is a 422, even if it matches.
⚠ Even if it matches, because accepting it would teach your code a false model: the day somebody changes that field to a different address, they would get a silent success and a message delivered to the wrong inbox. Refusing is the only version where what your code believes and what this API does cannot drift apart.
You still send from — the key pins the domain, not the whole address — so a pinned key needs two settings in your app rather than three: the key itself and the from-address.
This is for a contact form. Without it, the credential on your public web server can mail anybody in the world from your verified domain; with it, the worst a stolen key can do is send you junk. Everything else is unchanged: the key still sends from its pinned domain, still refuses a suppressed address, still rate-limits.
curl -X POST https://cf-email.jderrick.app/v1/emails \
-H "Authorization: Bearer $CF_EMAIL_FORM_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Contact form <noreply@yourdomain.com>",
"subject": "New enquiry from the website",
"reply_to": "person@example.com",
"html": "<p>…the form fields, escaped by you…</p>"
}'
Set reply_to to the address the person typed, so hitting reply answers them. ⚠ Escape the form fields before putting them in html — the body is yours and we do not touch it. And a control character in any field is refused: line breaks in a subject or a name are how a header gets appended by somebody else.
It cannot be added to a key that already exists. A key's identity is its message history and its webhook settings, and quietly changing what a live application may do is a surprise that gets found during an outage. Make another key.
⚠ Why they are separate. A signup key lives on the backend of a public website — it is the most exposed credential we issue. Every key is pinned to a verified sending domain, so if one key could do both, a leaked signup key would be a leak of your ability to send mail as your own domain. Split, the worst a leaked signup key can do is put an address into the one list it names, and even that address has to confirm by email before anything happens.
Using the wrong key gets you 403 scope_required, which names the fix.
⚠ Neither key belongs in a browser. Anything in front-end JavaScript is readable by anyone who opens the page source. Your signup form posts to *your* server, and your server calls us.
POST /v1/emails
curl -X POST https://cf-email.jderrick.app/v1/emails \
-H "Authorization: Bearer $CF_EMAIL_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: password-reset-8f21c3" \
-d '{
"from": "Acme <noreply@acme.com>",
"to": "person@example.com",
"subject": "Reset your password",
"html": "<p>Click <a href=\"https://acme.com/r/abc\">here</a> to reset.</p>"
}'
{ "id": "0f7d3a1e-6c2b-4c9a-9d31-2a5e8b6f4c10" }
Fields
| Field | Required | Notes |
|---|---|---|
from | yes | Name <email> or a bare address. The domain must be the one your key is pinned to. |
to | yes | One address, or a one-element array. More than one is refused — see below. |
subject | yes | |
html | one of | |
text | one of | If you send only html, we derive a plain-text part for you. Send your own when the wording matters. |
reply_to | no | A single address. |
headers | no | Object of string values, up to 13. Your provider may refuse particular headers; if it does, you get its refusal verbatim. |
Refused on purpose, each with a 422 naming the field: cc, bcc, attachments, scheduled_at, tags, template. They are refused rather than ignored — silently dropping attachments would send a receipt with no invoice and tell you it worked.
to takes one address because one message goes to one person. Fanning a single call out to five recipients would hide five separate deliveries, five separate bounces and five separate suppression decisions behind one id. Call it once per person.
Idempotency
Send an Idempotency-Key header — any string you can regenerate, such as your own reset-request id. It is how you retry safely.
| Situation | What you get |
|---|---|
| First call | 200 {"id": …} and the message is sent |
| Same key, same body, already sent | 200 with the same id, nothing re-sent |
| Same key, same body, still in flight | 200 {"id", "state"}, nothing re-sent |
| Same key, different body | 409 idempotency_key_reused |
Same key, after a 503 | The send is re-attempted on the same record |
That last row is the one worth building on: if you get a 503 or a 429, retry with the same Idempotency-Key. You cannot cause a double send that way, and you do not need to track whether the first attempt got through.
Keys stay usable for at least 24 hours (in practice, for as long as the message is retained).
GET /v1/emails/:id
curl https://cf-email.jderrick.app/v1/emails/0f7d3a1e-… \
-H "Authorization: Bearer $CF_EMAIL_KEY"
{
"object": "email",
"id": "0f7d3a1e-…",
"from": "Acme <noreply@acme.com>",
"to": ["person@example.com"],
"subject": "Reset your password",
"created_at": "2026-08-14T11:04:22.000Z",
"state": "sent",
"last_event": "delivered",
"last_event_at": "2026-08-14T11:04:31.000Z"
}
Metadata only — never the message body. Readable only by the key that sent it, so a leaked key cannot read another application's mail.
state is our record: sending, sent, retrying, failed, suppressed, or unknown (we could not establish what happened; a human should look). last_event is what the provider last told us: delivered, hard_bounce, complaint, and so on.
POST /v1/contacts
A subscribe box on your website. Needs a key with the Add contacts to a list permission.
curl -X POST https://cf-email.jderrick.app/v1/contacts \
-H "Authorization: Bearer $CF_EMAIL_SIGNUP_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "person@example.com",
"attributes": { "first_name": "Sam", "city": "Sydney" }
}'
{ "id": "0f7d3a1e-…", "state": "pending", "confirmation_sent": true }
⚠ The 202 is the point. Nobody is subscribed yet. This call records the request and emails the address a confirmation link. Pressing that link is what creates the contact and puts them on the list. Your page should say *"check your email"* — never *"you're subscribed"*.
That is not a formality. It is what makes an open signup endpoint safe: anyone can post any address, so the confirmation is the only thing proving the person holding that mailbox actually wants this.
Fields
| Field | ||
|---|---|---|
email | required | The address to subscribe |
attributes | optional | Flat object of your own fields. They become {{attr.first_name}} in the email templates, and merge with anything already known |
There is no list_id, and sending one is a 422. The list is fixed to the key, so a leaked key can add somebody to that one list and nowhere else. Need two lists? Two keys.
What comes back
| State | Status | What happened |
|---|---|---|
pending | 202 | Recorded, confirmation email sent. Not subscribed yet |
already_confirmed | 200 | They are already on this list and have not unsubscribed. No email was sent — say so on your page rather than showing an error. (Someone who unsubscribed and signs up again gets pending and the confirmation email, even though they are still on the list) |
confirmation_sent: false means the address was recorded but the email could not be handed to your provider; confirmation_error says why. The person can still be reached by submitting the form again.
Where the visitor is, without their IP
If you want to know roughly where subscribers are (the owner did, 2026-09-20), send it as attributes: your server already knows, and nothing has to be stored that identifies the person. On a site behind Cloudflare the request carries the visitor's country, region and city (request.cf.country, request.cf.region, request.cf.city in a Worker or Pages Function); pass them as cc, region and city. Those are the attribute names a Mailchimp export lands in, so imported and new contacts sort together on the contacts screen. It is a guess from the network, the same as Mailchimp's was, and it is coarse: a country, a state, a city, never an address. The Pages Function snippet on the list screen does this.
What we record — and why there is no IP field
We store the address, the attributes you send, the list, when the signup was requested, and when the confirmation link was redeemed.
The confirmation click is the consent record. That is the position, and it is a stronger one than an IP address gives you: a one-time link, sent to that mailbox and pressed from it, shows both that the person controls the address and that they acted. An IP shows a network location at a moment — through a carrier, a VPN, an office NAT, a shared machine — and it is the thing most often produced as evidence precisely because it is the thing nobody checks.
So there is no consent_ip field, and sending one is not supported:
- The call comes from your server, so the address we can see is your data centre, not the person. Storing that under a name like "consent IP" would be a record that looks like evidence and is not.
- An address in
attributesbecomes a mail-merge variable — one template mistake from being printed in an email to someone else.
If a complaint ever needs answering, the export on that contact's page carries the whole chain: the source, the time consent was given, and the confirmed signup with its request and confirmation timestamps.
Repeat submissions are safe
Post the same address twice — a retry after a timeout, a double-click, somebody who lost the first email — and you get one pending record and a fresh confirmation email, with the newest link being the one that works. There is no duplicate to clean up and no error to handle.
⚠ Do not send an Idempotency-Key here. This endpoint ignores the header entirely — the upsert above is what makes repeats safe, and there is nothing for a key to protect. (It is read only by POST /v1/emails, where a repeat with a different body is genuinely a bug worth refusing.)
The confirmation
The email comes from noreply@<your key's domain> under your organisation's name, through your own provider. It carries no unsubscribe footer, because there is nothing to unsubscribe from until they say yes.
An unconfirmed signup is deleted after seven days. It is not consent and not a record worth keeping.
Somebody who unsubscribed before
Confirming brings them back: the unsubscribe is cleared and they rejoin the list. That is deliberate — a person is allowed to change their mind, and the confirmation email is the gate that stops it happening by accident.
⚠ A hard bounce, a spam complaint or a repeatedly-bouncing address is not cleared, ever. None of those is a statement about what the person wants; they are statements about whether mail can be delivered at all. In practice the dead addresses exclude themselves — a hard-bounced address never receives the confirmation, so it can never confirm.
Errors specific to this endpoint
| Status | name | Meaning |
|---|---|---|
| 403 | scope_required | This key sends email; it cannot add contacts (or vice versa) |
| 403 | no_list_on_key | The key has the permission but no list pinned. Re-create it |
| 422 | validation_error | email missing or invalid, list_id supplied, or attributes is not a flat object |
| 429 | rate_limit_exceeded | More than 60 signups a minute from this key, or more than 3 confirmations to the same address in an hour. The message says which. The newest link already sent still works, so tell the person to check their inbox |
| 403 | domain_not_verified | The key's domain is not verified (any more). Same rule as sending — a confirmation is an email from your domain |
| 503 | provider_unavailable | The provider account behind the key is paused because its credentials failed |
A daily cap you set on the provider account applies here too: at the cap the signup is recorded and confirmation_sent is false, the same as any other provider refusal.
GET /v1/templates
What your organisation has to draft from. Needs the Draft a campaign permission — the same key you already use to create a draft, so listing costs you no extra access.
curl https://cf-email.jderrick.app/v1/templates \
-H "Authorization: Bearer $CF_EMAIL_KEY"
{
"object": "list",
"data": [
{
"object": "template",
"id": "102a0747-d4bb-46ef-bbc1-a9c5b532db03",
"name": "Raw MJML",
"editor_mode": "source",
"slots": ["events", "issue"],
"attrs": ["first_name"],
"body_bytes": 13062,
"updated_at": "2026-09-18T04:11:07.000Z"
}
]
}
| Field | Notes |
|---|---|
id | What POST /v1/campaigns wants as template_id. Stable across renames |
name | What a person called it here. Changes without warning — it is a label, never a key |
editor_mode | source, blocks, markdown or grapesjs. Informational; a draft works the same either way |
slots | The {{slot.…}} names this template declares, read from its body right now |
attrs | The {{attr.…}} names in the same body. See below |
body_bytes | The size of the template body in bytes. See below |
updated_at | When the template last changed, ISO 8601 |
⚠ Use slots rather than remembering them. They change whenever somebody edits the template, silently, and the failure is a 422 at draft time — or worse, a draft that renders with a hole in it because you filled a slot that no longer exists and missed one that appeared. If a template does not declare the slot your content needs, do not offer it.
Deleted templates simply stop appearing. There is no archived flag, because a deleted template is really gone. An id you are holding that no longer appears here will 404 at draft time, so treat "missing from this list" as "stop offering it" rather than waiting to find out.
The body is not returned, ever. Names, modes, placeholder names and a byte count only. To see what a template renders as, create a draft and open its url.
GET /v1/templates/:id
The same object for a single template, and a 404 not_found if it is gone. Use it when you are holding an id and want to know whether it is still there and what it declares, without fetching everything.
curl https://cf-email.jderrick.app/v1/templates/102a0747-… \
-H "Authorization: Bearer $CF_EMAIL_KEY"
Both routes build the response the same way, so a template's entry in the list and its own response are identical field for field.
Telling "no slots" apart from "we could not read them"
An empty slots array is ambiguous on its own. It means both "this template declares no slots" and "we failed to read the ones it has", and those look the same from your side. That is not a hypothetical: an escaping bug in this app destroyed slot placeholders in two of the four editor modes, and the only symptom anywhere was an empty array that looked perfectly normal.
attrs and body_bytes are there so you can tell:
* attrs comes from the same body, read by the same scanner. If attrs is non-empty then the body is demonstrably being read, so an empty slots beside it is a statement about the template rather than about us. * body_bytes separates a real template from an untouched starter. A new template is a few hundred bytes. The two that prompted this were 496 and 429 bytes, which is the tell that nobody had written anything in them yet.
⚠ This is evidence, not proof. Nothing computed from the body can certify the thing that computes it, and a fault that removed every placeholder kind at once would report both fields empty and look calm doing it. What it catches is the likelier case, one kind of placeholder breaking while the others keep working, which is the shape the real fault had.
If a template you expect to be fillable reports no slots, check body_bytes first. A few hundred bytes means somebody never wrote it.
This is the whole organisation's list, not the key's. Templates are made by people in the browser and belong to the organisation, so a brand-new key sees all of them. It is scoped to the key's organisation and to nothing wider.
POST /v1/campaigns
Your own application assembles the content; this app owns the template, the brand, the list, the unsubscribe handling and the sending; and a person opens the draft, reads it and presses send. Needs a key with the Draft a campaign permission.
The case it was built for is a newsletter whose content comes from somewhere you already have it — an events calendar, a product feed, a roster. That assembling is specific to you and belongs in your code. What this gives you is somewhere to put the result.
Slots
Put a named hole in your template wherever the content should go:
<mj-text>This is issue {{slot.issue}}</mj-text>
<mj-text>{{slot.events}}</mj-text>
Two other placeholders are worth knowing about while you are in a template: {{attr.first_name}} and friends fill in per recipient, and {{year}} becomes the year the campaign is sent, so a copyright line stays current on its own.
A slot holds markup, not just text — the output of whatever builds your content goes straight in. It is filled once, when the campaign is created, so what you get is an ordinary draft you can edit in the normal editor. A slot you do not fill renders as nothing; it never shows braces in an email.
curl -X POST https://cf-email.jderrick.app/v1/campaigns \
-H "Authorization: Bearer $CF_EMAIL_CAMPAIGN_KEY" \
-H "Content-Type: application/json" \
-d '{
"template_id": "8f21c3…",
"subject": "enews #274",
"from_name": "emus",
"from_email": "news@yourdomain.com",
"slots": {
"issue": "274",
"events": "<h2>Friday</h2><p>…</p>"
}
}'
{
"object": "campaign",
"id": "0f7d3a1e-…",
"status": "draft",
"subject": "enews #274",
"slots_filled": ["issue", "events"],
"slots_empty": [],
"url": "https://cf-email.jderrick.app/campaigns/0f7d3a1e-…"
}
Open url, read it, press send. That is the whole workflow.
Fields
| Field | Required | Notes |
|---|---|---|
template_id | yes | One of your own templates. Its id is in the URL on the template's page |
subject | yes | |
from_name | yes | |
from_email | yes | Must be on the domain your key is pinned to |
preheader | no | The line inboxes show after the subject |
slots | no | Object of slot name to markup. A name the template does not have is a 422 |
track_opens | no | Default false. See the caveat on the campaign screen before turning it on |
track_clicks | no | Default false |
There is no list_id, and sending one is a 422. The list is fixed to the key, the same as for a signup key. The domain and the provider account come from the key too. The only things your call chooses are the words.
An unknown slot name is refused, not ignored. A typo would otherwise give you a 201, a draft that looks right in the response, and a newsletter missing its content — the kind of thing found after sending rather than before.
It cannot send
There is no launch endpoint, and this permission does not grant one. The most a leaked campaigns key can do is leave drafts in your account. If launching over the API is ever added it will be a separate permission, not a widening of this one.
GET /v1/campaigns/:id
{
"object": "campaign",
"id": "0f7d3a1e-…",
"subject": "enews #274",
"status": "draft",
"recipient_count": 0,
"created_at": "2026-09-17T02:14:22.000Z",
"launched_at": null,
"completed_at": null,
"url": "https://cf-email.jderrick.app/campaigns/0f7d3a1e-…"
}
status is draft, launching, sending, sent or failed. Poll it if your app wants to know whether a human sent the draft yet.
Readable only by the key that created it, so a leaked key on one server cannot enumerate another application's campaigns. A campaign you made in the web interface has no key behind it and is not readable here.
Suppressions
If an address has hard-bounced or you blocked it by hand, this endpoint refuses to send to it and answers 422 recipient_suppressed. That is deliberate: continuing to mail a dead address is what destroys a sending reputation, and the refusal is visible rather than silent so your application can tell the user their address does not work.
An unsubscribe or a spam complaint does not block transactional mail. Someone who unsubscribed from your newsletter, or marked one as spam, still gets their password reset — they asked for it. Those suppressions block marketing only. If you want an address stopped entirely, suppress it by hand; that blocks everything.
Errors
Errors use Resend's shape, so an existing client library reads them:
{ "statusCode": 422, "name": "validation_error", "message": "`to` is not a valid email address." }
| Status | name | What to do |
|---|---|---|
| 401 | missing_api_key, invalid_api_key | Check the key; it may have been revoked. |
| 403 | from_domain_not_allowed | The from domain is not the key's domain. |
| 403 | domain_not_verified | Finish verifying the domain. |
| 409 | idempotency_key_reused | You reused a key with a different body. |
| 422 | validation_error | The message names the field. |
| 422 | recipient_suppressed | Do not retry; the address is blocked. |
| 429 | rate_limit_exceeded | Honour Retry-After, then retry the same key. |
| 502 | provider_error | Your provider refused it; the message is theirs, verbatim. |
| 503 | provider_unavailable | Retry the same key. |
429 covers two different things: too many API calls from this key (300/minute) and your provider account being at its own send rate. Both are solved by waiting and replaying the same idempotency key.
Webhooks
Set a webhook URL on your key (API keys → the key → Webhook) and we will POST an event to it whenever something happens to one of its messages. The signing secret is shown once, when you save the URL.
| Event | When |
|---|---|
email.sent | Your provider accepted the message. |
email.delivered | The receiving mail server accepted it. |
email.bounced | It bounced. data.bounce_type is hard or soft. |
email.complained | The recipient marked it as spam. |
email.failed | We could not send it, or the provider refused it. |
The body:
{
"type": "email.bounced",
"created_at": "2026-08-15T09:12:44.000Z",
"data": {
"email_id": "0100019874-…",
"from": "Support <support@example.com>",
"to": ["someone@example.net"],
"subject": "Reset your password",
"detail": "smtp; 550 5.1.1 user unknown",
"bounce_type": "hard"
}
}
data.detail is your provider's own words, unedited, whenever they sent any.
Verifying the signature
Every request carries two headers:
cf-email-timestamp: 1786783992
cf-email-signature: v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
The signature is v1= followed by the hex HMAC-SHA256 of timestamp + "." + rawBody, keyed with your signing secret. Verify against the raw request bytes, not a re-serialised copy of the parsed JSON — those are not always the same string.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, headers, secret) {
const timestamp = Number(headers['cf-email-timestamp']);
const signature = headers['cf-email-signature'];
if (!Number.isFinite(timestamp) || !signature) return false;
// Reject replays: an old signature is still a valid signature.
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected =
'v1=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
Reject anything more than five minutes old, and compare in constant time — a comparison that returns early leaks the signature one byte at a time.
Retries
Answer 2xx and we consider the event delivered. Anything else, or no answer at all, and we retry after 30 seconds, 5 minutes, 30 minutes, 2 hours and 8 hours. After the last one the delivery is marked failed and we stop; you can see every attempt, and the status or error we got, on the message.
Deliveries are not deduplicated end to end, so make your handler idempotent — process by data.email_id and ignore one you have already seen. We will not send you the same provider event twice, but a retry whose response we never saw can still arrive after you have processed it.
There is a working verifier in the repo at scripts/webhook-echo.mjs:
npm run webhook-echo -- --secret=<your signing secret>
It checks the headers, the skew and the signature, and prints what arrives. --fail=500 makes it reject everything so you can watch the retry schedule.
Retention
Sent messages — both the log entry and our stored copy of the contents — are kept for 90 days by default. Change it under Brand → Organisation → Keep transactional messages for. Set it longer if you have record-keeping duties; what those are for your organisation is yours to know.
Migrating from Resend
Change the base URL from https://api.resend.com to https://cf-email.jderrick.app, and the key from re_… to cfe_…. The from / to / subject / html / text / reply_to / headers fields and the Idempotency-Key header all behave the same way.
What you lose: cc, bcc, attachments, tags, scheduled_at, stored templates, and batch sending. If you use those, they are refused with a clear error rather than silently ignored — so a migration fails loudly on the first call rather than quietly for months.