How Do You Protect a Public AI Voice Demo From Denial of Wallet?

Key takeaways
- A per-visitor limit counted in each server's memory is not shared, so it does not cap total demo traffic.
- Our own audit found no global ceiling, no origin check, burst mode on and a shared workspace, though no sign of abuse so far.
- Retell documents a $0.10 per minute surcharge on burst calls, so a rush setting can raise the cost of a flood.
- Our first fix counted the provider's call list and missed unjoined tokens that stay valid for about two hours, so the final guard counts each token when it is issued.
- Do the worst-hour arithmetic with your own per-minute cost; ours came to the order of $700 before the fix and about $40 for a capped day after it.
Most advice about protecting an AI voice agent is written from the outside. Somebody lists the risks, quotes a standard, and tells you to add limits. I wanted to write one from the inside, about a system we run ourselves.
On our website there is a live voice demo. A visitor clicks a button, allows the microphone, and talks to an AI agent in the browser, set up as a receptionist for a dental practice, a salon or a heating company. It is on our voice agent service page and, at the time of writing, on 23 articles. Every one of those clicks starts a metered call on our account.
So we did to our own demo what we tell clients to do: we audited it for what the standard list of AI application risks calls "denial of wallet", where someone runs up your pay-per-use costs, and then we fixed what we found. This is the case study. It is deliberately specific, including the parts that do not flatter us, because the specifics are the point.
Here's what matters most:
- Our demo had call-length and silence limits on each agent, which is good, but its limit on how often a visitor could start a call was weaker than it looked, and nothing capped total spend.
- With a few lines of arithmetic from our own measured costs and the provider's published rules, the worst hour could have run to the order of $700. It had not happened. Nothing in our call history suggests an attack so far.
- Our first fix had a hole that we only found by testing the live site, an hour after deploying it. The final design counts every token the moment it is issued, in a small shared ledger, before asking the provider for anything.
- The guard fails closed: if it cannot count, no new call starts. It was tested against fake and real storage, including a flood of simultaneous requests.
- Three things remain outside the code and need a person: a dashboard setting, a separate workspace and a spend alert.
What the demo is, and what it is not
First, the facts of the setup, because they decide which risks apply.
The demo runs entirely in the browser. There is no phone number attached to any of the demo agents, which we confirmed by listing the phone numbers on the account: there are none. That matters because it rules out the best-known fraud against voice agents. International revenue share fraud, which I explain in what IRSF is and how it can run up an AI voice agent bill, needs a system that can place or transfer calls to arbitrary phone numbers. A browser demo with no phone numbers cannot. What it can suffer from is the ordinary kind of denial of wallet: lots of long calls.
Each of the three demo agents has two limits set on the agent itself: a maximum call length of 180 seconds, and an automatic hang-up after 20 seconds of silence. Those are the right limits, and they were in place from the start. A single call cannot cost more than three minutes of usage.
The click on the page does not call the provider directly. It asks our own server for a one-time token, and the server asks the voice provider, Retell, to register a call and hand back a token that lets the visitor's browser join. The provider's key never leaves the server. This is the standard, correct design, and it gives us a place to put limits: the server decides whether to issue a token.
Our server's original rule was simple: no more than six tokens per visitor per hour. Here is why we now think that was not enough.
What we found when we looked
We read the code, then checked the provider's account. This is the list, in the order I would fix it.
1. The visitor limit was kept in memory on each server instance. Our site runs on a platform where requests are handled by many small server instances that start and stop on demand. A counter kept in one instance's memory is not shared with the others, and it disappears when that instance stops. So the six-per-hour limit was really six per hour per instance, per restart. A visitor who happened to reach different instances, or a script that kept going until it landed on a fresh one, was never counted together. The Twilio author whose attack I describe in our security guide made a similar point about his own fix: a per-number limit alone still allows hundreds of calls across many numbers, so a global ceiling is needed.
2. There was no global ceiling at all. Nothing in our code stopped the total number of calls per hour or per day. Each visitor was limited. The crowd was not.
3. Anything could ask for a token. To confirm it, we sent a plain request to the live demo address from a terminal, with no browser, no web page and no origin. It returned a valid call token. A script does not need our page to start a call.
4. The account has "burst" switched on. Retell's standard allowance is 20 simultaneous calls on a pay-as-you-go plan, and our account shows exactly that: a limit of 20, with a burst limit of 60. Burst lets calls go over the standard limit instead of being rejected. Retell's documentation says burst calls carry an added $0.10 per minute surcharge on the entire call. The feature that prevents a busy signal during a rush also raises the price of every call during a flood, and it raises the ceiling on how many calls can run at once.
5. The demo shares a workspace with a real agent. The same Retell workspace holds another agent, our lead qualifier, and Retell's documentation says concurrency limits and burst settings apply per workspace. A flood of demo calls competes for the same simultaneous call slots as anything real. This is why Twilio's author recommends separate accounts, keys and budgets for every public demo.
6. Spend had no automatic stop. No ceiling on cost that cuts calls off. A provider alert to a person would help, but as the Twilio case shows, an attack can run for hours before anyone reads an email.
What the worst hour could have cost
The next step is arithmetic, and it is worth doing for any demo you run. These are our own calculations from our measured costs and the provider's published rules. They are not an observed bill.
From our own call history, the calls that actually connected totalled 12.7 minutes and cost about $1.70, so roughly 13 cents a minute for the language model, voice and speech recognition together. That is our measured rate for these agents and yours will differ.
A call ends at three minutes, so the cheapest way to keep the line busy is to keep starting calls. With 20 simultaneous calls at the standard rate, that is about 20 minutes of usage a minute, or around $160 an hour. If burst is allowed, as it is on our account, up to 40 more calls run at the burst rate, about 23 cents a minute with the surcharge, adding another $560 or so an hour. The total is on the order of $700 an hour, before counting any limits the provider applies to how fast new calls can start. I do not know that a real attacker would manage that. I do know that nothing in our own code would have stopped them from trying.
To keep this honest, here is the opposite evidence. We listed every call ever made on the demo agents. There are 20 in total. Three were early test calls in August. Sixteen were on October 7, the day the demo went live, which looks like our own launch testing. One was on October 11. Nothing has been started since. Twelve of the 20 ended with the provider's "user not joined" status, meaning a token was issued and nobody connected. There is no sign of abuse. There is also no sign of visitors: the demo has been on our blog pages for over a week and has not started a call.
I find that second fact useful. A demo that nobody uses is not a reason to leave it open. It is a reason to guard it cheaply, so it cannot cost money while you are not looking.
What we built
The principle behind the fix is the one the standard risk list gives for unbounded consumption: rate limits, per-user quotas and timeouts. We added five layers in front of the token.
A kill switch. One environment setting turns the demo off immediately, without a deployment. It is the control for the day something looks wrong.
An origin check. The request to start a demo must come from our own pages, which a browser always declares and a bare script usually does not. This is not strong, since a script can pretend, but it removes the laziest abuse and raises the cost of the rest.
The old per-visitor limit, kept as a cheap first filter. It still runs in memory and blocks quick repeats without a network call.
A shared ledger that every server reads and writes. This is the layer that matters, and we built it twice. Before the demo asks the voice provider for anything, our server writes one small entry to a ledger held in our content database, which is already in production and shared by every server instance. Each entry holds a time and a hashed visitor identifier, never a raw address. The server then counts entries: a visitor over a per-hour limit is refused, and the whole demo is refused above an hourly cap and above a daily cap of about a hundred. An entry that ends up over a limit is deleted again.
The order matters. We write first and count second, and each request counts only the entries that are not newer than its own. That turns the count into a place in a queue. Without it, when fifty requests arrive in the same instant, each sees the other forty-nine and all of them refuse themselves, so nobody gets in. With it, the first thirty get in and the rest are refused. Our first test of the ledger failed exactly this way, which is why I now believe in testing floods and not just single requests.
Failing closed. If the list cannot be read, because the provider is down or returns something unexpected, the server issues no token and tells the visitor the line is busy. A guard that fails open is a guard that switches itself off at the worst moment.
The first version had a hole. Our first design did not use a ledger. It asked the voice provider for the demo agents' calls in the last 24 hours and counted those, which needs no new storage. We deployed it, then tested it from outside, and the six calls we had just requested were not in the provider's list. A call that has been registered but not yet joined does not appear until it expires, and the provider's tokens stay valid for about two hours. So someone could request a large number of tokens, which would not be counted, and use them later. Unjoined tokens cost nothing, but the stockpile could be joined all at once. The calls that actually connect and cost money were counted; the tokens that lead to them were not.
That is why the final version counts the token itself, at the moment it is issued. The trade-offs are honest ones. It adds one write and one count to the content database on every click, a few hundred milliseconds. It makes the demo depend on that database being reachable. And it leaves a handful of throwaway entries that we prune. For a demo I accept all three. For a high-volume production system you would use a proper rate-limiting service.
With the daily cap in place, the worst day's usage is about 100 calls of three minutes each, or roughly 300 minutes. At our measured 13 cents a minute that is around $40, against an open-ended number before. Again, that is arithmetic, not a bill.
How we tested it
The guard is a small separate piece of code with its storage injected, so it can be tested without a website. We ran it against a fake store first: six calls from one visitor are allowed and the seventh is refused and its entry released; a different visitor is still allowed; thirty entries in the last hour trips the hourly cap; a hundred across the day trips the daily cap; a storage failure refuses; and a counting failure refuses and cleans up. Then a flood: sixty simultaneous requests from sixty different visitors let in exactly thirty.
Then we ran it against the real database, creating entries, counting them and deleting them, and fired twelve simultaneous requests from one visitor, of which exactly six were admitted. After deploying, we tested from outside the site: a request with no origin or a foreign origin was refused, requests from our own site got tokens, and the seventh request from one visitor was refused. We then deleted our own test entries so they would not use up the day's cap.
What this does not prove is how it behaves under a real, sustained attack. The lesson is the one we give clients: write the abuse cases first, test the live system and not only the code, and expect your first fix to have a gap.
What is still on the list
I said I would include the parts that do not flatter us. Three things are not solved by code.
Burst mode. It is a dashboard setting on the voice account, and our recommendation is to switch it off for the workspace that runs the public demo, accepting that a rush may get a busy signal instead of a surcharge.
Workspace separation. Ideally the public demo runs in its own workspace, with its own concurrency, burst setting and key, so that an attack on the demo cannot starve anything real. Retell's documentation says traffic in one workspace does not use slots in another. That is a bigger change than a code fix, because the agents and key have to move.
A spend alert. A provider-side budget and alert, connected to the kill switch, is the missing layer around the call. Today the kill switch is manual.
None of these is exotic. They are the ordinary boring controls, and they are the ones that get left undone because nothing has gone wrong yet.
What I would take from this
If you run a public voice demo, or a voice agent with any public front door, I would do five things this week. Check what limits are on each call. Check whether your visitor limits are really shared across your servers. Do the arithmetic for the worst hour using your own per-minute cost and your provider's burst rules. Put a daily ceiling and a kill switch in front of the token. And ask what shares the workspace with it.
If you want a voice agent built with these limits in from the first day, that is how we approach AI voice agent development. For the wider picture, read how to secure an AI voice agent against abuse and fraud. You can try the demo itself below.
FAQ
What is denial of wallet?
It is an attack that exploits pay-per-use pricing by running up costs, rather than taking a service offline. OWASP, which publishes the standard list of AI application risks, describes it as starting a large volume of operations to impose unsustainable costs on the provider. For a voice agent, it usually means lots of calls or long calls, since each minute is billed.
How do you protect a public voice demo from abuse?
Cap each call's length and silence, require requests to come from your own pages, limit calls per visitor, add a global hourly and daily cap, set a spend ceiling that cuts off automatically, and keep an off switch you can reach quickly. Run the demo in its own account or workspace so an attack cannot affect real customers. If the guard cannot check its limits, it should refuse to start a call.
How much could an attacker cost me with a voice agent demo?
It depends on your per-minute cost, call length cap and concurrency limits, so do the arithmetic for your setup. For ours, using a measured cost of about 13 cents a minute and a provider burst surcharge of $0.10 a minute, the theoretical worst hour was on the order of $700 before our fix. A documented attack on a different voice demo cost about $982 in roughly four hours.
Is a per-IP rate limit enough?
No. A per-visitor limit that is counted separately on each server is easy to get around, and even a shared one does not stop a crowd of different visitors. You also need a global cap per hour and per day, plus a spend limit.
What does "fail closed" mean for rate limiting?
If the system cannot work out whether a request is within limits, it refuses the request instead of allowing it. For a metered service that is the safer choice. The cost is that real visitors may sometimes see a busy message when the counter is unavailable.
Do I need a separate account or workspace for a public demo?
It is a good idea. Concurrency limits and budgets apply per account or workspace, depending on the provider, so a flood of demo calls can use the capacity real agents need. Twilio's guidance recommends separate accounts, keys and budgets for every public demo and revoking them after the event.
Get new articles when they publish
One email per post. No pitch, no spam.