DEV Community

Cover image for The HTTP Code Your AI Agent Doesn't Handle Yet: 402
Alex Spinov
Alex Spinov

Posted on • Originally published at blog.spinov.online

The HTTP Code Your AI Agent Doesn't Handle Yet: 402

Your fetch agent knows two endings to a request. 200: parse it. 403: back off, rotate, or skip. That branch has been the whole game for years.

There's a third ending now, and it's the one your code falls through. 402 Payment Required, with a dollar amount in the header. Cloudflare turned it on for Pay-Per-Crawl in July 2025. 403 punished you with retries — wasted time, nothing you couldn't see. A bare 402 isn't a charge by itself; it's a quote. But the moment your agent does the obvious thing — re-request and agree to the price — it's an invoice. And here's the part that bites: by default, your HTTP client has no brake for it.

TL;DR

  • Cloudflare Pay-Per-Crawl answers crawlers with HTTP 402 Payment Required plus a crawler-price header. To pay, the crawler re-requests with crawler-exact-price and expects 200. (Cloudflare, 2025-07-01)
  • On a synthetic 10-host fixture (prices I made up to exercise every branch), a naive agent that treats 402 like "just pay and move on" spent $0.9658 against a $0.10 budget — roughly 10x over budget, or 16x what the budgeted agent spent ($0.0600). Same numbers, two different baselines; I keep them straight below. It fetched 9 of 10 pages.
  • A 40-line handler that branches three ways (free API fallback / price cap / skip+log) spent $0.0600, stayed under budget — and got 6 of 10 pages. The cap costs you reach, not money you can't see.
  • This is the same 200/403/429 decision tree I've run across 2,190 production runs. 402 is a new leaf on it. The only difference is the dollar sign on the end.
  • The code below is stdlib-only, deterministic, no network. Copy it, run it, change the prices.

What actually changed

For most of HTTP's life, 402 was a placeholder. RFC 9110, §15.5.3, says it in full: "The 402 (Payment Required) status code is reserved for future use." (RFC 9110). That's the entire section. A status code that sat empty for decades.

Pay-Per-Crawl is the first time I've seen it wired into production at scale. The flow is plain. A crawler asks for a page. Instead of 200 or 403, the origin returns 402 with a header — crawler-price: USD XX.XX. If the crawler wants the content, it asks again, this time carrying crawler-exact-price to agree to the charge, and the origin serves 200. There's a proactive variant too, where the crawler leads with crawler-max-price on the first request. All of that is in Cloudflare's own announcement.

Stack Overflow and Cloudflare publicly ran a pay-per-crawl arrangement on Stack Overflow's data earlier this year, which is worth reading if you want the publisher's side of the deal. (I'll be honest about what I won't quote: a few aggregator posts floating around cite specific "−32% bot traffic / +27% revenue" pilot numbers. I went to the official Stack Overflow blog to confirm them and they aren't there. So I'm leaving them out. The argument doesn't need them.)

Here's the contrarian bit, and the reason this matters to anyone writing a fetcher. The "robots.txt is dead" takes are aimed at the wrong layer. Enforcement didn't disappear — it moved from a polite text file the server hopes you read, down to the network edge, where it's a real response with a real price. For a crawler that used to ask "am I allowed?", the question quietly became "how much?". And "how much" is a runtime policy decision, not a parsing problem. Your client library doesn't make policy decisions. You do.

The tree I already run, and where 402 plugs in

I'll put the original number on the table, because it's the only reason I have anything to add here. Across my published Apify actors I've logged 2,190 production runs lifetime — real jobs against real sites, not tutorial demos. The Trustpilot review scraper alone accounts for 962 of them. That's not a vanity stat; it's where the branch tree comes from.

Every one of those runs lives inside a decision tree keyed on the HTTP response:

  • 200 → parse it.
  • 403 → hard block. Back off, rotate identity, or skip and log. Old-world enforcement.
  • 429 → rate limited. Back off with jitter, retry later.

That tree has a property worth naming out loud: every branch is free. Wrong, sure — a 403 storm costs you wall-clock time and burned proxies. But it never debits an account. The worst a 429 does is make you wait.

402 breaks that property. It's a new leaf on the exact same tree, and structurally it sits right next to 403 — both are "the door is not simply open." But where 403 says no, 402 says not for free. That single difference forces three decisions your default HTTP client was never built to make:

  1. Is there a free or cheaper source for this host? A keyless API, a sitemap, a public dump. If yes, route there. $0.
  2. Is this single page worth its asking price? You need a per-page ceiling, or one expensive page quietly eats the run.
  3. Can I still afford it? A per-run budget that decrements as you spend, so the 50th 402 can't spend money the 5th already committed.

None of those three live in requests or httpx. They're policy. And on 402, policy is the whole ballgame.

Quick gut-check before the code, because I want you to feel why this isn't theoretical. The Trustpilot scraper ran 962 times. Imagine those targets sat behind Pay-Per-Crawl at a trivial $0.001 a page. At a few hundred pages per run, that's a real, recurring line item — pennies that compound into a number you'd put on an invoice. A naive "pay and move on" agent wouldn't even flinch. It'd just spend.

The handler

Here's the whole thing. Stdlib only, no network, deterministic — so the output you see is the output you'll get. The "network" is a fixture: ten hosts, each with how it responds, its price if it returns 402, and whether a free API exists for it.

Code maturity: toy/illustrative. This models the decision logic, not the wire protocol. Read the "what's faked" section after it before you ship anything near it.

#!/usr/bin/env python3
"""
HTTP 402 Payment Required handler for an autonomous fetch agent.
Deterministic, stdlib-only, no network. Simulates the Cloudflare Pay-Per-Crawl
flow: a page can answer 200 (free), 403 (hard block), or 402 + crawler-price
(paid). The agent decides per-page using a per-run price budget.

Policy on 402:
  1. paid-fetch  : price <= remaining budget AND <= per-page cap -> pay, re-request, expect 200
  2. api-fallback: a keyless/cheaper data source exists for this host -> use it, $0
  3. skip+log    : price too high / no budget -> do NOT pay, record decision, move on
Mirrors the 403/429 branch tree we already run in production (2,190 runs):
402 is just a new leaf with a price attached.
"""

# --- fixture: deterministic "network". Each entry = how a host responds to a crawl.
# status: what the origin returns on first crawl. price: USD per fetch if 402.
# has_api: a keyless/cheaper structured source exists for this host.
PAGES = [
    # host,                 status, price,  has_api
    ("docs.example.com",    402,    0.0008, True),   # cheap + api -> api wins (free)
    ("news.example.org",    402,    0.02,   False),  # mid price, no api -> pay if budget
    ("shop.example.net",    402,    0.25,   False),  # expensive, no api -> over per-page cap -> skip
    ("blog.example.io",     200,    0.0,    False),  # free, just fetch
    ("wiki.example.com",    402,    0.005,  True),   # cheap, api exists -> api (free)
    ("paywall.example.co",  402,    0.50,   False),  # very expensive -> skip
    ("feed.example.org",    402,    0.01,   False),  # mid, no api -> pay
    ("legacy.example.biz",  403,    0.0,    False),  # hard block (old-world) -> skip+log
    ("data.example.ai",     402,    0.03,   False),  # mid, no api -> pay
    ("store.example.dev",   402,    0.15,   False),  # > per-page cap -> skip
]

PER_PAGE_CAP = 0.05    # never pay more than 5 cents for a single page
RUN_BUDGET   = 0.10    # total we are willing to spend this run

def crawl(host, status, price, has_api, budget_left):
    """Returns (verdict, cost, served_status). Pure function of inputs + budget_left."""
    if status == 200:
        return ("FETCH_FREE", 0.0, 200)
    if status == 403:
        return ("SKIP_BLOCKED", 0.0, 403)
    if status == 402:
        # 1. prefer a free/cheaper structured source
        if has_api:
            return ("API_FALLBACK", 0.0, 200)
        # 2. refuse if a single page costs more than the cap
        if price > PER_PAGE_CAP:
            return ("SKIP_TOO_EXPENSIVE", 0.0, 402)
        # 3. refuse if it would blow the run budget
        if price > budget_left:
            return ("SKIP_NO_BUDGET", 0.0, 402)
        # 4. pay, re-request with payment header, expect 200
        return ("PAID_FETCH", price, 200)
    return ("SKIP_UNKNOWN", 0.0, status)

def run(pages, naive=False):
    spent = 0.0
    got_content = 0
    paid_count = 0
    rows = []
    for host, status, price, has_api in pages:
        if naive:
            # naive agent: treats 402 like "just pay and move on", no cap, no api,
            # no budget check -- the mistake we want to show.
            if status == 402:
                verdict, cost, served = ("PAID_FETCH", price, 200)
            elif status == 200:
                verdict, cost, served = ("FETCH_FREE", 0.0, 200)
            else:
                verdict, cost, served = ("SKIP_BLOCKED", 0.0, status)
        else:
            verdict, cost, served = crawl(host, status, price, has_api, RUN_BUDGET - spent)
        spent += cost
        if served == 200:
            got_content += 1
        if verdict == "PAID_FETCH":
            paid_count += 1
        rows.append((host, status, f"${price:.4f}", verdict, f"${cost:.4f}", served))
    return spent, got_content, paid_count, rows

def show(title, pages, naive):
    spent, got, paid, rows = run(pages, naive=naive)
    print(f"=== {title} ===")
    print(f"{'host':<22}{'orig':>5}{'price':>10}  {'decision':<19}{'paid':>9}{'served':>8}")
    for host, status, price, verdict, cost, served in rows:
        print(f"{host:<22}{status:>5}{price:>10}  {verdict:<19}{cost:>9}{served:>8}")
    print(f"-> content pages: {got}/{len(pages)}   paid fetches: {paid}   SPENT: ${spent:.4f} (budget ${RUN_BUDGET:.2f})")
    print()
    return spent, got

if __name__ == "__main__":
    print(f"per-page cap=${PER_PAGE_CAP:.2f}  run budget=${RUN_BUDGET:.2f}  pages={len(PAGES)}\n")
    naive_spent, naive_got = show("NAIVE agent (pays every 402, no cap/api/budget)", PAGES, naive=True)
    gated_spent, gated_got = show("BUDGETED agent (api-fallback / cap / skip+log)", PAGES, naive=False)
    overspend = naive_spent - gated_spent
    print(f"NAIVE spent ${naive_spent:.4f} for {naive_got} pages | BUDGETED spent ${gated_spent:.4f} for {gated_got} pages")
    print(f"Budgeted agent paid ${gated_spent:.4f} and stayed under the ${RUN_BUDGET:.2f} run budget; naive overspent by ${overspend:.4f} ({naive_spent/gated_spent:.1f}x) and blew the budget.")
    assert naive_spent > RUN_BUDGET, "naive should blow the budget"
    assert gated_spent <= RUN_BUDGET, "budgeted must respect the budget"
    # honest trade-off: the budgeted agent buys FEWER pages on purpose --
    # it refuses the expensive ones instead of silently draining the wallet.
    skipped = naive_got - gated_got
    print(f"Trade-off: budgeted skipped {skipped} expensive page(s) it refused to pay for. "
          f"That is the point -- a price ceiling costs you reach, not money you can't see.")
    assert gated_got <= naive_got, "budgeted trades reach for cost control (expected)"
    print("All asserts passed.")
Enter fullscreen mode Exit fullscreen mode

Run it yourself: python3 -I agent_402_handler.py. No flags, no deps.

The output

This is the real stdout, copy-pasted, not paraphrased:

per-page cap=$0.05  run budget=$0.10  pages=10

=== NAIVE agent (pays every 402, no cap/api/budget) ===
host                   orig     price  decision                paid  served
docs.example.com        402   $0.0008  PAID_FETCH           $0.0008     200
news.example.org        402   $0.0200  PAID_FETCH           $0.0200     200
shop.example.net        402   $0.2500  PAID_FETCH           $0.2500     200
blog.example.io         200   $0.0000  FETCH_FREE           $0.0000     200
wiki.example.com        402   $0.0050  PAID_FETCH           $0.0050     200
paywall.example.co      402   $0.5000  PAID_FETCH           $0.5000     200
feed.example.org        402   $0.0100  PAID_FETCH           $0.0100     200
legacy.example.biz      403   $0.0000  SKIP_BLOCKED         $0.0000     403
data.example.ai         402   $0.0300  PAID_FETCH           $0.0300     200
store.example.dev       402   $0.1500  PAID_FETCH           $0.1500     200
-> content pages: 9/10   paid fetches: 8   SPENT: $0.9658 (budget $0.10)

=== BUDGETED agent (api-fallback / cap / skip+log) ===
host                   orig     price  decision                paid  served
docs.example.com        402   $0.0008  API_FALLBACK         $0.0000     200
news.example.org        402   $0.0200  PAID_FETCH           $0.0200     200
shop.example.net        402   $0.2500  SKIP_TOO_EXPENSIVE   $0.0000     402
blog.example.io         200   $0.0000  FETCH_FREE           $0.0000     200
wiki.example.com        402   $0.0050  API_FALLBACK         $0.0000     200
paywall.example.co      402   $0.5000  SKIP_TOO_EXPENSIVE   $0.0000     402
feed.example.org        402   $0.0100  PAID_FETCH           $0.0100     200
legacy.example.biz      403   $0.0000  SKIP_BLOCKED         $0.0000     403
data.example.ai         402   $0.0300  PAID_FETCH           $0.0300     200
store.example.dev       402   $0.1500  SKIP_TOO_EXPENSIVE   $0.0000     402
-> content pages: 6/10   paid fetches: 3   SPENT: $0.0600 (budget $0.10)

NAIVE spent $0.9658 for 9 pages | BUDGETED spent $0.0600 for 6 pages
Budgeted agent paid $0.0600 and stayed under the $0.10 run budget; naive overspent by $0.9058 (16.1x) and blew the budget.
Trade-off: budgeted skipped 3 expensive page(s) it refused to pay for. That is the point -- a price ceiling costs you reach, not money you can't see.
All asserts passed.
Enter fullscreen mode Exit fullscreen mode

Read the naive block top to bottom. It pays for everything: a $0.0008 page, then a $0.25 page, then a $0.50 page, no hesitation, because nothing in its logic ever says no to a price. Final tally: $0.9658 on a $0.10 budget — about 10x over the budget itself, and $0.9058 more than the budgeted agent spent ($0.0600), which is the 16.1x ratio the script prints at the end. (Two baselines, one easy thing to garble, so I'm spelling both out: ~10x vs the budget, 16x vs the disciplined agent. All four figures are straight off the stdout above.) It got 9 of 10 pages — and that's exactly the trap. It looks productive. The damage is in the column you only check when the bill arrives.

The budgeted block makes different calls on the same ten hosts. Two cheap pages had a free API, so it took the API and paid nothing. Three pages priced above the $0.05 per-page cap got refused outright — SKIP_TOO_EXPENSIVE, served 402, no money spent. It paid for three. Total: $0.0600, under budget.

The trade-off I'm not going to hide

The budgeted agent got 6 pages. The naive one got 9. Three fewer. That's not a rounding error; it's the deal.

The cap means you walk away from shop.example.net, paywall.example.co, and store.example.dev — pages you could have had, for money. Sometimes one of those is the page that mattered. A price ceiling buys cost control by spending reach. You feel that loss immediately, in the result count. You do not feel an overspend until the invoice. That asymmetry is the entire reason to set the policy before the run, not after the bill.

So the right frame on 402 isn't "pay or get blocked." It's: decide, ahead of time, what a single page is worth to you, and what the whole run is worth to you. Then let the agent enforce both, coldly, on every leaf.

What's faked, and what production actually needs

I'd rather you trust the argument than the demo, so here's where the demo lies:

  • The fixture isn't a live Cloudflare endpoint. The prices, the has_api flags, the statuses — I made them up to exercise every branch. They're illustrative. Real Pay-Per-Crawl prices are set per-publisher and read off the crawler-price header on a live 402, not from a Python list.
  • The budget is in-memory. Reset on every run. A production handler needs a durable budget counter — a row in Postgres, a Redis key, something that survives a crash mid-run. Otherwise a restart re-arms the full budget and you double-spend.
  • There's no real payment. No crawler-exact-price header sent, no 200 actually returned, no money actually moved. PAID_FETCH is a label here. The real handler reads crawler-price, decides, re-requests with the agreement header, and reconciles what it was actually charged against what it expected.
  • Concurrency would break the naive budget check. Two workers reading budget_left at once can both think there's room. A real per-run budget needs an atomic decrement.

So treat this as the shape of the policy, not a drop-in. The shape is the point: a free-source check, a per-page cap, a per-run budget, and a logged skip. Wire those into your fetch loop and the live protocol bits are mechanical.

Where I'd draw the line — and where I'm genuinely unsure

I'll say what I'd ship and where I'd stop.

A per-page cap and a per-run budget, both hard, both durable: yes, day one. Free-source fallback before paying: yes, it's the cheapest win in the list. Per-domain price tiers, where you'll pay more for a domain you already know is high-value? I think that's right. But I haven't run it against real Pay-Per-Crawl prices, so I'm guessing at the tier boundaries. Call it ±a lot.

The one I keep going back and forth on: should an agent be allowed to pay autonomously at all? Letting code move money based on a header it didn't fully verify is the kind of thing that's fine 999 runs out of 1,000 and a disaster on the 1,000th. My instinct is a human-in-the-loop gate on the first 402 from any new domain, then autonomous within a per-domain ceiling after that. But I haven't lived through a real overspend incident on this yet — Pay-Per-Crawl is new, and I want to be straight that I have zero production payment runs behind that opinion. The 2,190 runs taught me the branch tree. They didn't teach me what it feels like when the leaf has a price.

So, real question, not a comment-bait one: where do you draw the line — a per-page cap, a per-run budget, or per-domain price tiers? And would you let an agent pay autonomously at all, or is a human-in-the-loop on the first 402 non-negotiable? If you've already shipped against Pay-Per-Crawl, I especially want to hear what broke.


I write about production scraping and what 2,190 real runs actually teach you — the failures, the costs, the branch trees the docs skip. Follow for the next batch of numbers, and drop your 402 policy in the comments. I read every one.

AI-disclosure: drafted with an AI writing assistant, edited by a human before publishing. The Python above is stdlib-only and was run on my machine (python3 -I); the output block is copied verbatim from stdout and the asserts pass deterministically. The $0.9658 / $0.0600 / 16.1x figures and the page counts are that script's exact output; the 2,190 / 962 run counts are from my own Apify production history; external claims link to primary sources.

Top comments (0)