← All docs

Protecting stock from being held

Anything a cart, a seat map or a booking form reserves can be reserved by a bot that never means to pay. OWASP calls it denial of inventory (OAT-021). The stock is not stolen; it is simply never available to anyone who would buy it:

  • Holding it. A competitor empties a rival's shelves into carts, a scalper holds a release before it goes on sale elsewhere, a seat map turns grey the minute an on-sale opens. Hundreds of units held from one client, none of them bought — from one address, or a proxy pool behind one browser.
  • Keeping it held. A hold lasts ten minutes on one site and an hour on another. The bot holds the item again the moment its hold lapses, and again, for as long as it wants the stock gone. Nothing lapses for long enough for a person to get it.

Each hold looks like a shopper adding to a cart. Across holds they do not, which is what an assessment with a hold sees.

What is being watched

The counts slide — each hold fading out over its window rather than all resetting on the hour. Units held and bought are counted over the last hour; one item's holds and lapses over the last day, because a cycle is as slow as the hold is long: with thirty-minute holds a bot holds an item twice in any hour, for ever, and an hour's count would never add up to anything.

  • Stock held and not bought. Most carts are left — an unpaid hold says nothing on its own. What a shopper does not do is hold a hundred units in an hour and buy none of them: that is a reason (velocity:holdsFromAddress, 30), three hundred a challenge on its own (60). What was bought from the same address is taken off first, so a wholesaler who holds four hundred and buys them is left alone.
  • From one device, whatever the address. Holding through a proxy pool is an address per hold or two, and one browser for all of them. When the holds carry the visitor's receipt (send the _abid cookie as sessionToken), the device is counted the same way (velocity:holdsFromDevice), and the larger of the two counts once.
  • Across an IPv6 /48. A tunnel broker hands out a /48 free: a fresh /64 of it for every few holds is a handful an address. What is held across the /48 is counted too, at sixteen times an address's marks — sixteen hundred units unbought a reason (velocity:holdsFromNetwork) — unless the devices that ran the agent there make it a campus's or a carrier's.
  • One item, held again and again. A shopper adds an item, perhaps removes it and adds it back. Four holds of one item in a day, from one address or device, with none of it bought in the hour, is a reason (inventory:reheld, 30), and eight a challenge on its own (60). Whoever passed a challenge is not asked again for these or for the volume above (the reasons are given at no points): an office buyer, a fan refreshing the seat map.
  • One item, held again as each hold lapses. This is the attack itself. When you report a hold that lapsed unpaid, the next hold of the same item from the same client knows: two lapses and a third hold is inventory:holdCycling (40, a challenge beside anything else), four lapses and a fifth a challenge on its own (60) — whatever the hold's length: ten minutes, half an hour or an hour. It is excused by nothing — a person whose card was declined holds again once or twice, not four times in a day.
  • The device's own holds, apart from its address's. With a receipt, a device's holds and lapses are counted on their own, and a lapse you report for an address and an item is the hold of the device that held it there last. An address is everyone's behind it — a mobile carrier's shared address, an office during a drop — so what an address cycled and this device did not is the reason at 40 at most, not a challenge on its own. Without a receipt the address is all there is to go by.
  • The client, as for every assessment.

An item's own totals — everyone's units held, everyone's purchases, how many addresses hold it — are reported (unitsForItem, purchasedForItem, holdersForItem) and never weighed against whoever holds it next: a popular item is held by many people. They are there for rules and for your own dashboards: a release that is all held and none bought is a release under attack. They are read from the item's newest 1,000 holders of the hour: exact below that, and past it what the latest thousand hold and bought (holdersForItem reads 1,000) — summed whole on every hold, a drop's many holders made its busiest minute its slowest.

The thresholds leave a family's weekly shop and an office's order alone. A limited release — a sneaker drop, a concert on-sale — can be held much tighter: the rule editor's Guard a limited release challenges a second hold of one item, or ten units, from one client before any of it is bought, on the path you give it. A person passes the challenge and keeps the cart — whoever passed is not asked again while the receipt lasts, though the hold that was challenged was counted and the retry is too; a hoarding script does not pass.

What to send as hold

When a hold is placed — an item added to a cart that reserves it, seats picked, a room or a table or a fare held while the buyer pays:

"hold": { "item": "EVENT-2291/SEC-A/ROW-3", "quantity": 2 }
  • item is your own id for what is held: an SKU, a variant, an event and its seat block, a flight and a fare class. Use the same id every time for the same stock, and at the granularity that runs out: the size, not the shoe. It is kept as a keyed digest, for a day and an hour, never as sent.
  • quantity is the units the hold reserves, 1 if you leave it out.

A change of quantity on a line already held is not a new hold: send the hold once, when it is placed. A cart that does not reserve stock — where anyone can still buy the last one while it sits in yours — has nothing to protect this way; assess its checkout instead (protecting checkout).

Reporting how each hold ended

The same endpoint as every outcome, with the address the hold was placed from:

  • success — it was bought, with the units bought;
  • failure — it lapsed unpaid, or was let go.
POST /api/v4/assess/outcome
{ "ip": "81.2.69.160", "hold": { "item": "EVENT-2291/SEC-A/ROW-3", "quantity": 2 }, "outcome": "failure" }

Report from wherever you know: the checkout when a hold is paid for, the job that expires holds when one lapses. Keep the address with the hold when you place it — you need it again when the hold ends. Purchases are what keep a real buyer's repeated holds from counting against them; lapses are what show a hold being kept.

From your server

verdict = antibot.assess(
    ip=client_ip,
    headers=raw_header_pairs,
    method="POST",
    url="https://tickets.example/cart",
    hold={"item": f"{event_id}/{block}", "quantity": len(seats)},
    session_token=request.cookies.get("_abid"),   # the device, where the agent ran
)
if verdict["action"] == "block":
    abort(403)
if verdict["action"] == "challenge":
    return redirect_to_challenge()

hold = inventory.hold(event_id, block, seats, ttl_minutes=10, ip=client_ip)

# later, in checkout:
antibot.report_hold_outcome(ip=hold.ip, item=hold.item, outcome="success", quantity=len(hold.seats))
# or, in the job that expires holds:
antibot.report_hold_outcome(ip=hold.ip, item=hold.item, outcome="failure", quantity=len(hold.seats))

The same in every server SDK: assess with hold, and reportHoldOutcome.

The response carries the counts:

"hold": {
  "unitsFromAddress": 120, "purchasedFromAddress": 0, "itemsFromAddress": 30,
  "unitsFromDevice": 0, "purchasedFromDevice": 0,
  "unitsFromNetwork": 0, "purchasedFromNetwork": 0,
  "itemHoldsFromAddress": 3, "itemLapsesFromAddress": 2, "itemPurchasedFromAddress": 0,
  "itemHoldsFromDevice": 0, "itemLapsesFromDevice": 0, "itemPurchasedFromDevice": 0,
  "unitsForItem": 412, "purchasedForItem": 6, "holdersForItem": 41
}

and rules can read every one of them as hold.* — for example to hold back anyone at all once a release is plainly being hoarded:

hold.present and hold.unitsForItem >= 500 and hold.purchasedForItem <= 5 and hold.itemHoldsFromAddress >= 2

Knowing it is happening

Edge traffic counts the reasons like any other, and the protection checklist on the Proxy page says whether holds are being assessed and their purchases and lapses reported.