Spend Ceilings for Public Endpoints
Learn how to bound spend on endpoints that send email, SMS, or webhooks: reserving before the side effect, releasing on failure, rolling windows, and canonical limit keys.
Introduction
Most endpoints cost CPU. A few cost money every time they succeed. A subscription form sends an email. A password reset sends an SMS. A "notify me" button fires a webhook into a metered queue. The request is free to make, expensive to serve, and the person making it is anonymous.
Rate limiting is the usual answer, and it is necessary, but it is not the interesting part. The interesting failures are the ones that survive a correct rate limiter: the concurrent pair that both pass a limit of one, the window that resets at midnight so two messages land sixty seconds apart, the failed retry that burns a slot without sending anything, and the configuration typo that multiplies the ceiling tenfold without anyone noticing.
This article is about bounding that spend so the worst case is a number you can state in advance, rather than a number you discover on an invoice. The examples use TypeScript and SQL against a single-primary relational store, but the ideas apply to any endpoint whose success has a per-unit cost.
Separate the Throttle From the Ceiling
These look like the same mechanism and are not.
A throttle protects the service. It is per-caller, measured in seconds or minutes, and approximate is fine — nobody minds if a limit of sixty requests a minute occasionally admits sixty-two.
A ceiling protects the budget. It is global, measured over a billing period, and approximate is useless. The entire value of a ceiling is that it is a hard number; one that admits "roughly" the right amount of spend has not bounded anything.
That difference decides where each lives. A throttle can sit in an eventually consistent store — a cache with tens of seconds of propagation delay is fine for smoothing traffic. A ceiling cannot. Propagation delay is exactly long enough for a burst to walk past a limit whose only job is stopping bursts. Put the ceiling somewhere a counter read back in the same statement is the real count: a single-primary database, a durable object, a store with atomic increments and a return value.
Write both down as separate numbers with separate windows before you write either one in code. Conflating them is how people end up with a per-IP limit doing a job it cannot do, because any attacker with a second IP address has doubled your bill.
Reserve the Spend, Do Not Merely Permit It
Here is the shape almost everybody writes first.
const lastSend = await db.lastSendAt(recipient);
if (lastSend && now - lastSend < COOLDOWN) {
return accepted();
}
await sendEmail(recipient); // 200ms, sometimes 8s
await db.recordSend(recipient, now); // the proof, written lastIt reads correctly and it is broken. The gap between the check and the record is the duration of a network call. Every request that arrives inside that window reads "never sent", judges itself eligible, and proceeds. Two concurrent requests send two messages against a limit of one.
You cannot close this by wrapping it in a transaction, because the expensive part is a third-party API call and that cannot live inside a database transaction. The fix is to change what the write means. Stop writing a record that the work happened and start writing a reservation that the work is about to happen — before the call, in the same atomic statement that decides eligibility.
INSERT INTO senders (recipient, sends, last_send_at)
VALUES (?1, 1, ?2)
ON CONFLICT(recipient) DO UPDATE SET
sends = CASE
WHEN senders.last_send_at IS NULL OR senders.last_send_at <= ?3
THEN senders.sends + 1 ELSE senders.sends END,
last_send_at = CASE
WHEN senders.last_send_at IS NULL OR senders.last_send_at <= ?3
THEN ?2 ELSE senders.last_send_at END
RETURNING sends, last_send_atParameter ?2 is now, ?3 is the cooldown boundary. RETURNING hands back the post-update row, which tells the caller whether it is the one holding the reservation.
const row = await reserve(recipient, now, now - COOLDOWN);
if (row.last_send_at !== now) {
return accepted(); // somebody else got there first
}
await sendEmail(recipient);The reservation is the permission. There is no second check, because a second check is where the race lives. A concurrent request runs the same statement, sees a last_send_at that is inside the cooldown, and stands down — without ever reading a value that another request is still in the middle of earning.
Release What You Reserved
Reserving before the work means the reservation can outlive work that never happened. The provider returns a 500, the request times out, the process is killed mid-flight — and the recipient now carries a cooldown for a message they never received.
So the failure path has to give the reservation back.
UPDATE senders
SET sends = CASE WHEN sends > 0 THEN sends - 1 ELSE 0 END,
last_send_at = NULL
WHERE recipient = ?1 AND last_send_at = ?2The last_send_at = ?2 guard is the important half. It matches only the exact reservation this request wrote, so a slow release arriving after a newer reservation has legitimately taken its place cannot undo it.
This is easy to skip and expensive to omit. Without a release, a single bad minute at your provider costs every affected user a full cooldown window — and their retry, thirty seconds later, is refused by a reservation held for a message that does not exist. The failure mode is invisible in testing and infuriating in production, because the people it hits are exactly the people who wanted your product enough to try twice.
Put the Ceiling Outside the Write
Ordering matters as much as the mechanism.
If you reserve first and consult the global ceiling second, then a request the ceiling refuses has already mutated state. In a system I worked on recently, that meant rotating a confirmation token for a message that was then never sent — quietly invalidating the live link already sitting in the recipient's inbox, and mailing no replacement.
The rule that avoids this: every gate that can refuse the work runs before the write that commits to it. The write is what happens once all the gates have said yes. Anything that fails after that point releases what it took.
free checks method, content type, body size, origin, honeypot, syntax
per-caller throttle counters keyed on the caller
bot verification the first thing that costs a network call
per-recipient window is this destination inside its cooldown?
global ceiling is there budget left at all?
-------------------------------------------------- gates end here
reserve the atomic write
send the expensive part
on failure release everything reserved above
Note the secondary ordering inside the gates: nothing that costs a network call runs before something free can reject the request. A malformed body should never reach your bot-verification provider. That is not an optimisation, it is the difference between a flood costing you nothing and a flood costing you one upstream call per request.
Choose Windows That Cannot Be Walked Around
Two mistakes recur, and both produce limits that look right in tests.
Calendar buckets. A key like recipient:2026-08-21 with a limit of one per day resets at midnight UTC rather than twenty-four hours after the send. An attacker sends at 23:59:30 and again at 00:00:30: two messages, a minute apart, repeatable every night. Use a stable key whose row carries its own expiry, so the window starts at the first hit and rolls from there.
// The key does not encode the date. The row's own expiry defines the window.
await consume(`inbox:${canonical(recipient)}`, { limit: 1, windowSeconds: 86_400 });Uncanonicalised identity. A limit keyed on the literal string the user submitted is a limit on strings, not on people. For email, [email protected], [email protected] and [email protected] may all arrive in one inbox. Fold the address to a canonical form for the key, and keep the original for delivery.
const canonical = (email: string): string => {
const at = email.lastIndexOf('@');
let local = email.slice(0, at);
const domain = ALIASES[email.slice(at + 1)] ?? email.slice(at + 1);
const plus = local.indexOf('+');
if (plus > 0) local = local.slice(0, plus);
if (DOT_INSENSITIVE.has(domain)) local = local.split('.').join('');
return local.length > 0 ? `${local}@${domain}` : email;
};Be honest about the limits of this. Folding rules are provider-specific and incomplete; somebody with an inbox whose aliasing scheme you have not encoded still gets one message per alias. What stops that from being unbounded is the global ceiling sitting behind it, which is precisely why the ceiling is not optional.
The general lesson outlives the email example. Every limit key is an identity claim. Before you ship one, ask what else maps to the same real-world resource — a phone number with punctuation, a URL with a trailing slash, a customer with two accounts.
Make the Ceiling a Number You Can Defend
Pick the ceiling from the bill, not from comfort.
If your plan includes three thousand messages a month, a hundred a day is not an arbitrary round number — it is thirty days at a hundred, which is the included allowance exactly. A ceiling derived that way is defensible in a review, and the worst case is a sentence rather than a shrug.
Two properties make it operationally useful. A ceiling of zero should be a working kill switch that leaves the endpoint answering normally, so the response to abuse is a configuration change rather than a deploy. And the refusal should be logged loudly while the caller sees the ordinary success response — the person hitting your form is not the person you are defending against.
Configuration deserves more suspicion than it usually gets, because the ceiling is only as good as the number that reaches it.
const boundedInt = (raw: string | undefined, fallback: number, min: number, max: number, name: string) => {
const text = String(raw ?? '').trim();
if (text.length === 0) return fallback;
// parseInt reads "1e9" as 1 and "500abc" as 500 — a plausible-looking
// limit nobody asked for. Demand the whole string.
if (!/^\d+$/.test(text)) {
console.warn(`${name} is not a whole number ("${text}"); using ${fallback}`);
return fallback;
}
const parsed = Number.parseInt(text, 10);
const clamped = Math.min(Math.max(parsed, min), max);
if (clamped !== parsed) console.warn(`${name} of ${parsed} clamped to ${clamped}`);
return clamped;
};Clamping bounds a typo; it does not make one safe. A ceiling of 1000 typed instead of 100 sits comfortably inside a generous range and costs ten times as much, so keep the range close to the default and log anything that gets adjusted. A range wide enough to feel flexible is a range wide enough for a stray keystroke to matter.
Fail Closed, and Answer Uniformly
Three properties protect the endpoint itself.
Fail closed. If bot verification times out, returns a 500, or hands back something unparseable, refuse the request. Treating an unreachable verifier as a pass converts one bad minute upstream into an open relay for anyone watching.
Answer uniformly. Every outcome that is not an outright error should return the same body: new recipient, recipient already known, recipient inside a cooldown, ceiling exhausted. Otherwise the endpoint answers a question it was never meant to answer, which is whether a given address is in your database.
Answer at the same moment. Identical bodies are not enough if one path waits on a network round trip and the others return immediately; response time is the oracle. Defer the side effect and return straight away.
ctx.waitUntil(deliver(recipient, token));
return accepted();Deferring also removes an error branch that only a not-yet-known recipient could ever reach, which is the same leak wearing a different hat.
Test the Ceiling by Removing It
A passing test suite proves nothing about a control until you have watched it fail without it.
The procedure is mechanical: delete the control, run the test that claims to cover it, confirm the test fails, restore the control. Anything that still passes was never testing what its name says. This catches two distinct problems — a test that asserts at the wrong layer, where some other mechanism was quietly doing the work, and a test that never ran at all, because removing the control broke compilation and a suite that does not build reports no failures.
Concurrency needs its own test, because sequential tests cannot see the bug that matters here.
test('two overlapping requests produce exactly one send', async () => {
const [a, b] = await Promise.all([subscribe(address), subscribe(address)]);
expect(sendsFrom(a) + sendsFrom(b)).toBe(1);
});It is worth internalising how ordinary the failure is. A suite can pass completely against code with a live spend bug, because every test in it exercised one request at a time and the bug needs two. Thoroughness about the failure modes you thought of is not coverage of the ones you did not.
Conclusion and Next Steps
Bounding spend on a public endpoint comes down to a few decisions that are cheap to make early and expensive to retrofit:
- Keep the throttle and the ceiling separate, and put the ceiling somewhere strongly consistent.
- Reserve the spend in one atomic statement before the side effect, and treat that reservation as the permission.
- Release the reservation whenever the work does not happen, guarded on the exact reservation you wrote.
- Run every refusing gate before the write that commits to the work.
- Use rolling windows and canonical keys, so neither the clock nor an alias walks around the limit.
- Derive the ceiling from the bill, clamp the configuration, and make zero a kill switch.
- Verify each control by removing it, and test concurrency explicitly.
If you are building this alongside other reliability work, the natural neighbours are idempotency keys for the retry path, a transactional outbox if the side effect must survive a crash, and per-tenant budgets if the spend is attributable to accounts rather than to anonymous callers. The mechanism is the same in each case: decide what the write means before you decide where to put it.
About this story
This article was written by Gen-AI using GPT 5.5 or Opus 4.7. Verify technical guidance before using it in production systems.