cf-email068c43f

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:

Permissions

When you create a key you choose one permission, and a key never has more than one:

PermissionCan doCannot do
Send emailPOST /v1/emails, GET /v1/emails/:idTouch contacts, lists or campaigns
Send email, pinned to one recipientThe same, to one fixed addressMail anybody else
Add contacts to a listPOST /v1/contactsSend anything at all
Draft a campaignPOST /v1/campaigns, GET /v1/campaigns/:idSend 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

FieldRequiredNotes
fromyesName <email> or a bare address. The domain must be the one your key is pinned to.
toyesOne address, or a one-element array. More than one is refused — see below.
subjectyes
htmlone of
textone ofIf you send only html, we derive a plain-text part for you. Send your own when the wording matters.
reply_tonoA single address.
headersnoObject 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.

SituationWhat you get
First call200 {"id": …} and the message is sent
Same key, same body, already sent200 with the same id, nothing re-sent
Same key, same body, still in flight200 {"id", "state"}, nothing re-sent
Same key, different body409 idempotency_key_reused
Same key, after a 503The 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
emailrequiredThe address to subscribe
attributesoptionalFlat 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

StateStatusWhat happened
pending202Recorded, confirmation email sent. Not subscribed yet
already_confirmed200They 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:

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

StatusnameMeaning
403scope_requiredThis key sends email; it cannot add contacts (or vice versa)
403no_list_on_keyThe key has the permission but no list pinned. Re-create it
422validation_erroremail missing or invalid, list_id supplied, or attributes is not a flat object
429rate_limit_exceededMore 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
403domain_not_verifiedThe key's domain is not verified (any more). Same rule as sending — a confirmation is an email from your domain
503provider_unavailableThe 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"
    }
  ]
}
FieldNotes
idWhat POST /v1/campaigns wants as template_id. Stable across renames
nameWhat a person called it here. Changes without warning — it is a label, never a key
editor_modesource, blocks, markdown or grapesjs. Informational; a draft works the same either way
slotsThe {{slot.…}} names this template declares, read from its body right now
attrsThe {{attr.…}} names in the same body. See below
body_bytesThe size of the template body in bytes. See below
updated_atWhen 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

FieldRequiredNotes
template_idyesOne of your own templates. Its id is in the URL on the template's page
subjectyes
from_nameyes
from_emailyesMust be on the domain your key is pinned to
preheadernoThe line inboxes show after the subject
slotsnoObject of slot name to markup. A name the template does not have is a 422
track_opensnoDefault false. See the caveat on the campaign screen before turning it on
track_clicksnoDefault 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." }
StatusnameWhat to do
401missing_api_key, invalid_api_keyCheck the key; it may have been revoked.
403from_domain_not_allowedThe from domain is not the key's domain.
403domain_not_verifiedFinish verifying the domain.
409idempotency_key_reusedYou reused a key with a different body.
422validation_errorThe message names the field.
422recipient_suppressedDo not retry; the address is blocked.
429rate_limit_exceededHonour Retry-After, then retry the same key.
502provider_errorYour provider refused it; the message is theirs, verbatim.
503provider_unavailableRetry 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.

EventWhen
email.sentYour provider accepted the message.
email.deliveredThe receiving mail server accepted it.
email.bouncedIt bounced. data.bounce_type is hard or soft.
email.complainedThe recipient marked it as spam.
email.failedWe 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.