How the attack works
Inventory bots are a speed business. Two things are being bought: the ability to act faster than a human, and the ability to do it from enough identities that no per-anything limit binds.
- Direct API calls against the same endpoints your own front end uses, skipping the interface entirely. Anything enforced only in the browser does not exist for them.
- Residential proxy pools, one exit per session, so per-IP rate limiting has nothing to count.
- CAPTCHA solving services, either farms of people or automated solvers, cheap enough per solve that the challenge is a rounding error at bot volume.
- Pre-warmed accounts with saved payment details, aged deliberately so account-age heuristics read them as established customers.
The economics are what make this durable. If the resale spread on a seat is €80, an operator can spend a lot per attempt and still print money.
Why CAPTCHAs and rate limiting make it worse
This is the part worth internalising, because the standard response actively helps the attacker.
A CAPTCHA costs the bot operator a small per-solve fee and costs your customer time, and some customers the purchase. On a release where the constraint is speed, adding a uniform time cost to every participant advantages whoever can pay it fastest — which is the automation. You have not slowed the bots relative to humans; you have slowed humans relative to bots.
Per-IP rate limiting has the same shape. The bot has thousands of addresses. Your customers share a handful: a corporate NAT, a mobile carrier range, a household. The limit binds on the customers and never on the operator.
Both controls are individually reasonable and become perverse against an adversary with a lower per-attempt cost than your users. That is worth checking before adding either.
What the verdict returns
Screened at the hold or add-to-basket step, which is where inventory is actually removed from sale, with one server-side call to POST /v1/evaluate. An example response (an excerpt) for a scripted browser behind a residential proxy, with the customer account’s ID sent as accountId:
{
"decision": "block",
"risk_score": 90,
"reasons": [
"proxy_detected",
"automation_detected",
"multi_account_device"
],
"network": {
"vpn": false,
"proxy": true,
"datacenter": false,
"tor": false,
"residential": false
},
"device": {
"automation": true,
"antidetect": false,
"linked_accounts": 22,
"multi_account": true
}
}
Either of the first two reasons blocks on its own by default. network.proxy is true while network.datacenter is false: a consumer address relaying someone else’s traffic, which cloud-range lists never flag and per-IP limits cannot count across sessions. (network.residential reads false whenever proxy or datacenter is true: it means neither flag fired, not that the exit sits on a home ISP.) device.automation is a statement about the browser environment, a scripted or headless browser, not about request rate — so it can fire on the first check of a session, once the browser check has completed, rather than after a pattern has built. Against a bot that holds the seat before a human has finished loading the page, a control that needs to observe a pattern has already lost.
device.linked_accounts appears only when you send accountId and the browser check completed. It counts how many of your accounts this device has been seen with, stored as one-way hashes and never linked across customers, and adds multi_account_device once that count passes one. It does not change the default decision: an operator who mints a new device profile per account can keep it at 1, so the block here rests on the proxy and automation signals.
The direct-API bots above never run your page, so their hold requests arrive without the browser tokens. That is missing evidence, not a clean result (an HTTP evaluation without a token comes back with degraded: true): once you enforce, hold a reservation that arrives without them rather than treating it as allow.
Where to put the decision
At the hold, not at checkout. Inventory leaves availability the moment a seat is reserved, so a bot that is blocked at payment has still denied that seat to a customer for the length of the hold window. Screening at the reservation step is the difference between preventing the harm and documenting it.
Keep a challenge for the review band rather than removing it entirely. A VPN on its own is a review, not a block, so a customer on a VPN at a laptop solves it and continues; the point is that they are now a small fraction of sessions rather than all of them.
Start in watch mode: log each decision and let every hold through until the log matches what your release-day data already shows, then enforce. On the console’s Rules tab, a rule changes the decision only for the signal it names, so “Multi-accounting = Review” leaves the response above a block: the proxy and automation signals keep their default weight.
The arithmetic
A worked model. Replace the inputs with yours.
| Input | Model value | Yours |
|---|---|---|
| Units per release exposed to bots | 1,200 | — |
| Share taken by automation | 35% | — |
| Margin you lose per unit resold elsewhere | €22 | — |
| Releases per year | 12 | — |
1,200 × 35% × €22 × 12 = €110,880 a year in direct margin. The direct number may be the smaller half. The second-order costs — infrastructure sized for bot load rather than customer load, checkout failures during releases, and the customer who stopped trying after the third sell-out — do not appear in this table and can be larger. If you can measure release-day conversion for customers who reached checkout and abandoned, that is the number to put next to this one.
Why WAFs and rate limiting fall short
A WAF is built for attacks against your application: injection, traversal, known exploit signatures. A scalper bot performs no attack at all. It makes well-formed, entirely legitimate requests to public endpoints, in the correct order, with valid sessions. There is no signature to match, because nothing is malformed.
That is the structural gap: the request is fine and the actor is the problem, which is a question about the client and its connection rather than about the payload. Keep the WAF for what it is good at, and put the actor question somewhere it can actually be asked.