Skip to content

tutorials

Rate limiting your email API on Cloudflare Workers

August 14, 2026

#cloudflare-workers#rate-limiting#email-api#workers-kv#tutorial
Rate limiting your email API on Cloudflare Workers
Visual: Alex Storm on okslop

You stood up a /v1/send endpoint, gave it a bearer token, and shipped it. Then a customer's cron job double-fired, or a key leaked, and your Worker happily sent ten thousand messages in two minutes. Now half your mail is in spam and you're reading a Spamhaus listing. A rate limit is the cheap insurance you skipped.

An open send endpoint is the most dangerous route in your whole app. Not because of the bill (Workers and edge sending are cheap), but because volume is reputation. Senders earn deliverability slowly and lose it fast, and a runaway loop or a stolen token can burn through years of good behavior in an afternoon. A rate limit is one of several defenses that protect that standing; the rest are in the ways to build and protect your sender reputation, and a sudden unthrottled burst is itself one of the reasons transactional email lands in spam. The fix here is a per-caller rate limit that caps how much any one client can send in a window.

This is one of those problems that looks like five lines and turns out to have three layers. We'll build the five-line version first because it's genuinely useful, then walk into the race conditions and boundary bursts that make it leak, and finish with the honest tradeoffs between KV, Durable Objects, and Cloudflare's native limiter. At the end, the shortcut if you'd rather not own send throttling at all.

How do I rate limit an email API on Cloudflare Workers?

Identify the caller, keep a counter in Workers KV keyed by that identity plus a time bucket, increment it on each request, and return 429 once it exceeds the limit. KV is a globally-replicated key-value store with per-key TTLs, which is exactly the shape a rate-limit counter needs: a number that resets on a schedule. Start by binding a namespace.

npx wrangler kv namespace create RATE_LIMIT
🌀 Creating namespace with title "mailer-RATE_LIMIT"
✨ Success!
Add the following to your configuration file:
[[kv_namespaces]]
binding = "RATE_LIMIT"
id = "a1b2c3d4e5f6..."
{
  "name": "mailer",
  "main": "src/index.ts",
  "compatibility_date": "2026-06-01",
  "kv_namespaces": [
    {
      "binding": "RATE_LIMIT",
      "id": "a1b2c3d4e5f6...",
    },
  ],
}

Here is the smallest thing that works: a fixed-window counter that allows N sends per minute per caller.

type Bindings = { RATE_LIMIT: KVNamespace };

const LIMIT = 60; // sends per minute, per caller
const WINDOW_SECONDS = 60;

async function checkRateLimit(
  kv: KVNamespace,
  identity: string,
): Promise<{ allowed: boolean; count: number }> {
  // Bucket by the current minute. The key changes every 60s, so old
  // windows are abandoned (and TTL cleans them up).
  const bucket = Math.floor(Date.now() / 1000 / WINDOW_SECONDS);
  const key = `rl:${identity}:${bucket}`;

  const current = parseInt((await kv.get(key)) ?? "0", 10);
  if (current >= LIMIT) {
    return { allowed: false, count: current };
  }

  // Increment and write back. TTL slightly longer than the window so a
  // late read in the same minute still sees the count.
  await kv.put(key, String(current + 1), { expirationTtl: WINDOW_SECONDS * 2 });
  return { allowed: true, count: current + 1 };
}

Wire it into the send handler before any delivery work happens:

import { Hono } from "hono";

const app = new Hono<{ Bindings: Bindings }>();

app.post("/v1/send", async (c) => {
  // identity should be per-caller; see the next section for hardening.
  const token = (c.req.header("authorization") ?? "").replace("Bearer ", "");
  const identity = token || c.req.header("cf-connecting-ip") || "anon";

  const { allowed, count } = await checkRateLimit(c.env.RATE_LIMIT, identity);
  if (!allowed) {
    return c.json({ error: "rate_limited" }, 429);
  }

  // ... validate body and deliver (covered in the Hono email API guide)
  return c.json({ accepted: true, used: count }, 202);
});

export default app;

That deploys, and a caller hammering the endpoint gets cut off at 60 per minute:

for i in $(seq 1 61); do
  curl -s -o /dev/null -w "%{http_code}\n" -X POST \
    https://mailer.you.workers.dev/v1/send \
    -H "authorization: Bearer test-key" -d '{}'
done | sort | uniq -c
  60 202
   1 429

It works. It's also wrong in three ways that matter, which we'll fix in order: the identity is raw, the window allows a double-size burst, and the counter races.

How should I identify the caller for a rate limit?

Limit per authenticated caller, not per IP, and never use the raw token as a KV key. The raw bearer token is a secret; writing it into key names scatters it across KV's storage and your logs. Hash it first. And IP-based limits are a fallback for unauthenticated traffic, not your primary axis, because many legitimate callers share one egress IP (corporate NAT, other Workers) while an attacker rotates through many.

async function identityFromToken(token: string): Promise<string> {
  // Hash the token so the secret never appears in a KV key or log line.
  const data = new TextEncoder().encode(token);
  const digest = await crypto.subtle.digest("SHA-256", data);
  return [...new Uint8Array(digest)]
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("")
    .slice(0, 32); // 128 bits of the hash is plenty for a key
}

In a multi-tenant API you almost certainly already look the token up in D1 to find the tenant and their allowed from domain (the pattern in the Hono transactional email API guide). Use the tenant id as the rate-limit identity, and read the limit itself off the tenant row. That lets you sell tiers (the free plan gets 100/day, the pro plan 50,000/day) without redeploying:

type Tenant = { id: string; sendLimitPerMin: number };

app.post("/v1/send", async (c) => {
  const token = (c.req.header("authorization") ?? "").replace("Bearer ", "");
  const tenant = await lookupTenant(c.env.DB, token); // your D1 query
  if (!tenant) return c.json({ error: "invalid_token" }, 401);

  const { allowed } = await checkRateLimit(
    c.env.RATE_LIMIT,
    tenant.id,
    tenant.sendLimitPerMin, // per-tenant limit, not a global constant
  );
  if (!allowed) return c.json({ error: "rate_limited" }, 429);

  return c.json({ accepted: true }, 202);
});

Pass that limit through checkRateLimit as a parameter instead of the module-level LIMIT constant, and you have per-tenant quotas with zero extra infrastructure. Per-tenant send limits are one piece of running many customers on one account; the tips for running multi-tenant email on one Cloudflare account cover the rest, and a per-caller throttle is one line on the Cloudflare Workers email checklist for production.

What is the difference between a fixed window and a sliding window?

A fixed window counts inside clock-aligned buckets and resets at the boundary, which allows a burst of up to 2x your limit straddling the reset; a sliding window weights the previous bucket to smooth that out. The problem with the fixed window above: a caller can send 60 messages at 12:00:59 and another 60 at 12:01:01, which is 120 messages in two seconds while never exceeding "60 per minute" in any single bucket. For email, that boundary burst is exactly the spike you're trying to prevent.

A sliding-window counter fixes it by reading both the current and previous bucket and blending the old count by how much of the previous window still overlaps the trailing minute.

async function checkSlidingWindow(
  kv: KVNamespace,
  identity: string,
  limit: number,
): Promise<{ allowed: boolean; remaining: number; resetSeconds: number }> {
  const now = Date.now() / 1000;
  const window = WINDOW_SECONDS;
  const bucket = Math.floor(now / window);
  const prevKey = `rl:${identity}:${bucket - 1}`;
  const curKey = `rl:${identity}:${bucket}`;

  const [prevRaw, curRaw] = await Promise.all([kv.get(prevKey), kv.get(curKey)]);
  const prev = parseInt(prevRaw ?? "0", 10);
  const cur = parseInt(curRaw ?? "0", 10);

  // Fraction of the previous window still inside the trailing 60s.
  const elapsed = now / window - bucket; // 0..1 through the current bucket
  const weighted = prev * (1 - elapsed) + cur;

  if (weighted >= limit) {
    const resetSeconds = Math.ceil((bucket + 1) * window - now);
    return { allowed: false, remaining: 0, resetSeconds };
  }

  await kv.put(curKey, String(cur + 1), { expirationTtl: window * 2 });
  return {
    allowed: true,
    remaining: Math.max(0, Math.floor(limit - weighted - 1)),
    resetSeconds: Math.ceil((bucket + 1) * window - now),
  };
}

Now the 12:00:59-then-12:01:01 trick is caught: just after the boundary, elapsed is near zero, so almost the full previous-minute count still counts against the limit. You pay one extra KV read for it, which at the edge is cheap. For an email API, where a boundary burst is a deliverability event, the trade is worth it.

How do I return a correct 429 response?

Return 429 Too Many Requests with a Retry-After header in seconds, plus X-RateLimit-* headers so callers back off intelligently instead of hammering you. A bare 429 works, but a well-behaved client (or a queue consumer) can do much better if you tell it when to retry and how much it has left. This is the difference between a caller that politely waits 12 seconds and one that retries in a tight loop and makes the problem worse.

app.post("/v1/send", async (c) => {
  const tenant = await lookupTenant(c.env.DB, /* token */ "...");
  if (!tenant) return c.json({ error: "invalid_token" }, 401);

  const { allowed, remaining, resetSeconds } = await checkSlidingWindow(
    c.env.RATE_LIMIT,
    tenant.id,
    tenant.sendLimitPerMin,
  );

  c.header("X-RateLimit-Limit", String(tenant.sendLimitPerMin));
  c.header("X-RateLimit-Remaining", String(remaining));
  c.header("X-RateLimit-Reset", String(resetSeconds));

  if (!allowed) {
    c.header("Retry-After", String(resetSeconds));
    return c.json(
      { error: "rate_limited", retry_after: resetSeconds },
      429,
    );
  }

  return c.json({ accepted: true }, 202);
});

A throttled caller now gets actionable headers:

curl -s -i -X POST https://mailer.you.workers.dev/v1/send \
  -H "authorization: Bearer over-limit-key" -d '{}' | grep -i 'ratelimit\|retry-after\|HTTP'
HTTP/2 429
x-ratelimit-limit: 60
x-ratelimit-remaining: 0
x-ratelimit-reset: 14
retry-after: 14

One more rule specific to email: when you push throttled sends onto a queue (instead of rejecting outright), don't return 429 to the original caller at all, return 202 and let the queue drain at the allowed rate. Use 429 only when you're genuinely refusing the request. Mixing the two confuses retry logic. The queue-backed pattern is its own topic, covered in sending email reliably at volume with Queues, and the broader set of reliability shapes is in the Cloudflare Queues patterns for reliable email sending.

Is Workers KV accurate enough for rate limiting?

KV is good enough for soft abuse-prevention limits but not for hard, exact caps, because its writes are eventually consistent. Two requests that arrive in the same window can both get the count 41, both decide they're under the limit of 60, and both put 42, so one send goes uncounted. Under heavy concurrency, or with callers hitting different Cloudflare locations, the counter drifts low and a determined caller can exceed the limit by a meaningful margin.

For a send endpoint, ask what kind of limit you actually need:

  • Soft limit (abuse prevention). You want to stop runaway loops and leaked keys from sending 100x your normal volume. A little drift is fine: 60/min that occasionally allows 70 still caps the blast radius. KV is the right tool, and everything above is enough.
  • Hard limit (billed quota, contractual cap). A paid plan promises exactly 50,000 sends and not one more, or a compliance rule caps a stream. KV's drift is unacceptable here. You need a strongly-consistent counter.

For the hard case, you have two strongly-consistent options on Cloudflare. A Durable Object gives you a single-threaded, single-instance counter per key, so increments are serialized and exact:

// A Durable Object is the source of truth for one tenant's counter.
export class TenantLimiter {
  state: DurableObjectState;
  constructor(state: DurableObjectState) {
    this.state = state;
  }
  async fetch(req: Request): Promise<Response> {
    const limit = Number(new URL(req.url).searchParams.get("limit") ?? "60");
    const count = (await this.state.storage.get<number>("count")) ?? 0;
    if (count >= limit) return new Response("429", { status: 429 });
    await this.state.storage.put("count", count + 1); // serialized, exact
    return new Response("ok");
  }
}

Or use Cloudflare's native Rate Limiting binding, which is purpose-built and strongly consistent per key with no storage to manage:

{
  "unsafe": {
    "bindings": [
      {
        "name": "SEND_LIMITER",
        "type": "ratelimit",
        "namespace_id": "1001",
        "simple": { "limit": 60, "period": 60 },
      },
    ],
  },
}
const { success } = await c.env.SEND_LIMITER.limit({ key: tenant.id });
if (!success) return c.json({ error: "rate_limited" }, 429);

The practical answer for most email APIs: use KV's sliding window as the cheap, global first line of defense, and reach for a Durable Object or the native binding only on the specific limits that are billed or contractual. Don't pay the coordination cost of strong consistency on a limit whose only job is to stop abuse.

Common failure modes (and what they mean)

A caller is over the limit but KV still lets some requests through

Expected with KV under concurrency: eventually-consistent reads under-count. If the leak is unacceptable, that limit needs a Durable Object or the native Rate Limiting binding, not KV.

Every caller behind one office shares one limit and trips it

You're keying on IP, and they're behind one NAT egress. Key on the authenticated token or tenant id instead; reserve IP keys for unauthenticated traffic.

Counters never seem to reset

Your KV key doesn't include the time bucket, so it's one ever-growing counter. The key must contain Math.floor(now / window) so it rolls over, and the TTL must be set so abandoned buckets expire.

A burst of double the limit gets through right at the minute boundary

That's the fixed-window flaw. Switch to the sliding-window counter, which weights the previous bucket and closes the boundary gap.

The shortcut: don't own send throttling at all

Everything above is the correct shape of API rate limiting, and it's worth understanding because the patterns apply far beyond email. But notice what you're actually defending: your sending reputation. The rate limit exists so a runaway caller can't torch your domain's standing with mailbox providers. That means a good email stack should throttle sends as a property of the sender, not something you bolt on per endpoint and hope you got right.

This is where Tinbox fits. It deploys the sender, queue, and per-stream send throttles into your own Cloudflare account, so a campaign or a transactional burst is paced to protect deliverability automatically, with no KV counter for you to maintain and no per-email pricing in the path:

npx tinbox init yourdomain.com
import { tinbox } from "tinbox/cf";

// Sends are queued and paced by the sender's own throttles.
// A loop that fires 10k of these won't blast them out all at once.
await tinbox(c.env).send({
  to,
  from: "hi@yourdomain.com",
  subject: "Welcome",
  html: "<p>Thanks for signing up.</p>",
});

You still rate-limit your public endpoint to stop abuse (the KV pattern above is perfect for that), but the part that actually protects your domain, pacing the outbound stream, is handled by infrastructure you own and can read. The hard 80% stops being your problem.

If you're weighing owning this stack against a hosted API, here's the cost math once volume climbs and an honest comparison of the alternatives.

Deploy this yourself: npx tinbox init yourdomain.com. The sender's throttling and queue configuration is documented in the Tinbox docs.

Frequently asked questions

How do I rate limit an email sending API on Cloudflare Workers?

Identify the caller (bearer token, tenant id, or IP), build a KV key from that identity plus a time bucket like the current minute, increment the counter on each request, and return HTTP 429 once it exceeds your limit. Set a KV expiration so old windows clean themselves up. This is cheap and global but eventually consistent, so two simultaneous requests can both read the same count before either writes. For strict limits use a Durable Object or the Workers Rate Limiting binding instead.

What is the difference between a fixed window and a sliding window rate limit?

A fixed window counts requests inside a clock-aligned bucket (for example, all sends between 12:00:00 and 12:01:00) and resets at the boundary. It is simple but allows a burst of up to 2x the limit straddling the reset. A sliding window weights the previous window's count by how far you are into the current one, which smooths that boundary burst at the cost of one extra KV read and a little arithmetic.

Is Workers KV accurate enough for rate limiting?

KV is good enough for soft, abuse-prevention rate limits but not for hard guarantees. Writes are eventually consistent and can take time to propagate between locations, so concurrent requests in different regions can over-count against a shared limit. If you need an exact cap (for example, a paid plan's hard monthly quota), use a Durable Object as the single source of truth or Cloudflare's native Rate Limiting binding, both of which are strongly consistent per key.

What HTTP status code should a rate-limited email API return?

Return 429 Too Many Requests, and include a Retry-After header (in seconds) plus optional X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers so callers can back off intelligently. Returning 429 instead of a generic 500 lets well-behaved clients and queues distinguish throttling from real failures and retry at the right time.

Why rate limit an email API at all?

An open or weakly-limited send endpoint is an abuse magnet: a leaked key or a buggy loop can fire thousands of messages, spike your costs, and torch your domain's sending reputation so legitimate mail starts landing in spam. A per-caller rate limit caps the blast radius of both attackers and your own bugs, which protects deliverability far more than it protects your bill.