{"slug": "apify-pay-per-event-billing-four-ways-i-charged-users-for-nothing", "title": "Apify pay-per-event billing: four ways I charged users for nothing", "summary": "A developer running 51 scrapers on Apify behind seven MCP servers details four distinct ways their pay-per-event billing system charged users for empty results or failed to charge for real ones. The bugs, each invisible from the outside, were caught by a single check that filters out bookkeeping rows and explicit not-found entries. The developer emphasizes that raw item count is not the same as delivered results and recommends registering billing events before deploying any tool that references them.", "body_md": "I run 51 scrapers on Apify behind seven MCP servers. Agents call them as tools\n\nover the [Model Context Protocol](https://modelcontextprotocol.io/specification/2025-06-18):\n\nClaude asks for a company's SEC filings, or Reddit posts about a product, or\n\nopen roles at a competitor, and a tool call goes out and comes back with rows.\n\nBefore I shipped any of it I wrote down one rule. A tool call that returns\n\nnothing must never fire a billable event.\n\nIt reads like a small rule. Under Apify pay-per-event billing it is the single\n\nhardest thing I have had to enforce, and I got it wrong four separate times, in\n\nfour different ways, each one invisible from the outside. Every one of them was\n\ncaught by the same check, which I will get to at the end. If you are putting a\n\npaid Actor behind an agent, this is the post I wanted to read first.\n\nNone of this needs special tooling, but it assumes you are past these three\n\nthings:\n\n`apify-client`\n\npackage and an\nAPI token, so you can call that Actor from code rather than from the Console.\nThree of the four bugs below only appear when the caller is not a browser.A person who runs your Actor and gets nothing back looks at the empty dataset\n\nand asks for a refund. That feedback loop is fast and it is loud.\n\nAn agent does not do that. It gets an empty result, says \"I could not find\n\nanything for that company,\" and moves on. The user never sees your Actor's\n\nname. Nobody files a complaint. If you were charging for those calls, you would\n\nfind out from your revenue graph looking suspiciously good, which is not a\n\nthing anyone investigates.\n\nSo the usual signal that tells you billing is broken does not arrive. You have\n\nto go looking.\n\nMy scrapers append a bookkeeping row to the end of every dataset. Run stats,\n\ncounts, that kind of thing:\n\n```\n{ \"_type\": \"summary\", \"posts_scraped\": 0, \"charged_for\": 0 }\n```\n\nMy billing wrapper decided \"did this return anything?\" by counting dataset\n\nitems. A search that found nothing still returned one item. One is more than\n\nzero. The call billed.\n\nWorse, one of my resolver Actors emits an explicit not-found row rather than\n\nstaying silent:\n\n```\n{ \"resolved\": false, \"query\": \"asdfghjkl ltd\" }\n```\n\nSame outcome. Gibberish in, a row out, a charge fired.\n\nThe fix is not complicated once you see it, but you have to see it:\n\n``` js\nconst billable = items.filter(\n  (i) => i._type !== 'summary' && i.resolved !== false\n);\nif (billable.length === 0) return { found: false, billed: null };\n```\n\nThe general shape of the bug: **raw item count is not the same as delivered\nresults.** Any row your own pipeline adds for its own purposes will lie to a\n\n`Actor.charge`\n\ndoes not throw\nThis one cost real money and it is the one I would most want other people to\n\nknow about.\n\nI shipped a new Indeed tool. The PPE event for it did not exist in Console yet,\n\nbecause the browser tab died halfway through the pricing wizard and I did not\n\nnotice. The tool went live.\n\nIt worked. It hit Indeed, came back with ten real postings, and told the caller:\n\n```\n{ \"billed\": { \"event\": \"search_jobs_indeed\", \"count\": 1 } }\n```\n\nThe platform recorded:\n\n```\n{ \"chargedEventCounts\": {} }\n```\n\nSo it served real data, over residential proxy that we pay for by the gigabyte,\n\nfor free, while reporting that it had charged.\n\n`Actor.charge`\n\ndoes not throw when you hand it an event name that was never registered. It\n\nreturns. My billing code had no try/catch because there was nothing to catch.\n\nThe rule I now follow without exception: **register the event in Console before\ndeploying any tool that references it.** Not after, not in the same sitting,\n\n`tools`\n\narray and`GET /v2/acts/{id}`\n\n`pricingInfos`\n\n.There is a hardening step I have not built yet and probably should: have the\n\nserver refuse to register a tool whose billing event is missing from the\n\nActor's own `pricingInfos`\n\nat startup. That turns a silent revenue leak into a\n\nloud boot failure. Flagging it as a gap rather than pretending I solved it.\n\n`prefill`\n\nis not `default`\n\nThis one did not cost money. It quietly broke an Actor for every programmatic\n\ncaller for an unknown length of time, which is arguably worse.\n\nMy Google Trends Actor declared its proxy input like this:\n\n```\n{\n  \"proxyConfiguration\": {\n    \"type\": \"object\",\n    \"prefill\": { \"useApifyProxy\": true, \"apifyProxyGroups\": [\"RESIDENTIAL\"] }\n  }\n}\n```\n\n`prefill`\n\npopulates the form in the Console. That is all it does, and the\n\n[input schema specification](https://docs.apify.com/platform/actors/development/actor-definition/input-schema/specification/v1)\n\nsays so plainly once you go and read it. Open the Actor in a browser, the field\n\nis filled in, you hit run, it works.\n\nCall the same Actor from the API, or from a schedule, or from an MCP server,\n\nand `proxyConfiguration`\n\narrives as `undefined`\n\n. No residential proxy, against\n\nan endpoint that returns 429 to datacenter IPs on sight.\n\nEvery human test passed. Every programmatic caller failed, including my own MCP\n\nserver calling my own Actor. The fix is a real `default`\n\n, plus a fallback in\n\ncode so the schema and the runtime cannot drift apart:\n\n``` js\nconst proxy = input.proxyConfiguration ?? {\n  useApifyProxy: true,\n  apifyProxyGroups: ['RESIDENTIAL'],\n};\n```\n\nI then swept all 96 Actors on the account for the same pattern. Sixteen had it.\n\nFifteen were harmless because the code already had a fallback. One was a live\n\ngap on an Actor doing 146 runs a month. If you built your Actors by cloning a\n\ntemplate, and most of us did, go and check. It is a ten minute grep.\n\nTwo of my Actors showed \"17 of 17 runs succeeded\" for weeks while returning\n\nzero rows to everybody.\n\nNot failing. Succeeding. The target had changed its markup, the parser matched\n\nnothing, the Actor wrote its summary row and exited zero. Every dashboard was\n\ngreen. The 30-day success rate on the Store listing said 100%.\n\nBilling was correct throughout, nobody was charged, which is the only reason\n\nthis was not a refund event. But the listing was selling something that did not\n\nwork, and the stat I would have pointed at to prove it worked was the stat\n\nhiding the problem.\n\n**Run status tells you your process exited cleanly. It tells you nothing about\nwhether a customer got data.** They are different questions and only one of\n\nEvery one of these was found the same way. After a call, compare what your own\n\ncode thinks it billed against what the platform actually recorded:\n\n``` js\nconst run = await client.run(runId).get();   // NOT the runs-list endpoint\nconst platform = run.chargedEventCounts ?? {};\nconst mine = { [event]: 1 };\n\nif (JSON.stringify(platform) !== JSON.stringify(mine)) {\n  console.error('BILLING MISMATCH', { runId, mine, platform });\n}\n```\n\nOne detail that wasted an afternoon: `GET /v2/acts/{id}/runs`\n\nreturns an\n\nabbreviated record with `chargedEventCounts: {}`\n\nand `pricingInfo: null`\n\neven\n\nwhen charges definitely exist. Only the\n\n[get-run endpoint](https://docs.apify.com/api/v2/actor-run-get),\n\n`GET /v2/actor-runs/{runId}`\n\n, has the real numbers. Do not conclude \"nothing\n\nwas charged\" from the list endpoint. I did, twice.\n\nThat comparison is the only thing that detects this whole class of bug. Unit\n\ntests will not, because your code is behaving exactly as written. Manual\n\nConsole testing will not, because the Console is the one caller that gets the\n\nprefills. Watching your revenue will not, because the failure mode is revenue\n\nthat looks fine.\n\nI now run it on every tool on every server as part of the release check. It has\n\ncaught something every single time I have added tools.\n\nUnrelated to billing but it will bite you in the same week:\n\n** Apify Standby\nreturns 504\non any response over five minutes**, whatever timeout you set yourself.\n\nI had a composite tool that chains an Amazon product search into a review pull.\n\nAmazon runs 45 to 110 seconds for a search and about 107 for reviews, so I set\n\nan internal budget of 420 seconds to be safe. The gateway killed it before my\n\nown tool ever finished. 280 seconds fits and works.\n\nIf your Actor genuinely runs for 20 minutes, it cannot be a synchronous tool\n\ncall at all. It has to return a run ID immediately and let the agent collect the\n\nresult later. Worth designing for on day one rather than discovering at 420\n\nseconds.\n\nWrite the invariant down before you write the billing code. Mine is one\n\nsentence and it has paid for itself repeatedly, mostly by making it obvious\n\nwhich of my assumptions were wrong.\n\nThen assume you have broken it, and go and check against the platform's own\n\nnumbers rather than your own. Four for four, that is where I found it.\n\nIf you want to test your own Actor for this today, there are three things worth\n\ndoing in order. Call one tool you already ship with an input you know returns\n\nnothing, then read the run back from the get-run endpoint and compare\n\n`chargedEventCounts`\n\nagainst what your code says it billed. Grep your input\n\nschemas for `prefill`\n\non any field that the runtime actually depends on, and\n\ngive each one a real `default`\n\n. Then take any Actor sitting at a perfect 30-day\n\nsuccess rate and check its delivered rows per run, because that is the number\n\nthe success rate is hiding. Each one is a short job. All three found something\n\non my account.\n\nThe Mine Works builds and maintains 82 web scrapers on the Apify Store, with\n\nseven MCP servers over them so AI agents can call the data as tools. All of it\n\nruns on Apify pay-per-event billing, which is why one rule sits above the rest:\n\na tool call that returns nothing must never fire a billable event. Most of what\n\ngets published here is a record of how that rule got broken and what the fix\n\nlooked like, with the run counts and the failure modes left in rather than\n\ntidied away.", "url": "https://wpnews.pro/news/apify-pay-per-event-billing-four-ways-i-charged-users-for-nothing", "canonical_source": "https://dev.to/apify/apify-pay-per-event-billing-four-ways-i-charged-users-for-nothing-9m1", "published_at": "2026-08-31 18:58:08+00:00", "updated_at": "2026-08-31 19:23:54.411774+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-infrastructure"], "entities": ["Apify", "Model Context Protocol", "Claude", "Indeed"], "alternates": {"html": "https://wpnews.pro/news/apify-pay-per-event-billing-four-ways-i-charged-users-for-nothing", "markdown": "https://wpnews.pro/news/apify-pay-per-event-billing-four-ways-i-charged-users-for-nothing.md", "text": "https://wpnews.pro/news/apify-pay-per-event-billing-four-ways-i-charged-users-for-nothing.txt", "jsonld": "https://wpnews.pro/news/apify-pay-per-event-billing-four-ways-i-charged-users-for-nothing.jsonld"}}