Apify pay-per-event billing: four ways I charged users for nothing 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. I run 51 scrapers on Apify behind seven MCP servers. Agents call them as tools over the Model Context Protocol https://modelcontextprotocol.io/specification/2025-06-18 : Claude asks for a company's SEC filings, or Reddit posts about a product, or open roles at a competitor, and a tool call goes out and comes back with rows. Before I shipped any of it I wrote down one rule. A tool call that returns nothing must never fire a billable event. It reads like a small rule. Under Apify pay-per-event billing it is the single hardest thing I have had to enforce, and I got it wrong four separate times, in four different ways, each one invisible from the outside. Every one of them was caught by the same check, which I will get to at the end. If you are putting a paid Actor behind an agent, this is the post I wanted to read first. None of this needs special tooling, but it assumes you are past these three things: apify-client package and an API token, so you can call that Actor from code rather than from the Console. Three 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 and asks for a refund. That feedback loop is fast and it is loud. An agent does not do that. It gets an empty result, says "I could not find anything for that company," and moves on. The user never sees your Actor's name. Nobody files a complaint. If you were charging for those calls, you would find out from your revenue graph looking suspiciously good, which is not a thing anyone investigates. So the usual signal that tells you billing is broken does not arrive. You have to go looking. My scrapers append a bookkeeping row to the end of every dataset. Run stats, counts, that kind of thing: { " type": "summary", "posts scraped": 0, "charged for": 0 } My billing wrapper decided "did this return anything?" by counting dataset items. A search that found nothing still returned one item. One is more than zero. The call billed. Worse, one of my resolver Actors emits an explicit not-found row rather than staying silent: { "resolved": false, "query": "asdfghjkl ltd" } Same outcome. Gibberish in, a row out, a charge fired. The fix is not complicated once you see it, but you have to see it: js const billable = items.filter i = i. type == 'summary' && i.resolved == false ; if billable.length === 0 return { found: false, billed: null }; The general shape of the bug: raw item count is not the same as delivered results. Any row your own pipeline adds for its own purposes will lie to a Actor.charge does not throw This one cost real money and it is the one I would most want other people to know about. I shipped a new Indeed tool. The PPE event for it did not exist in Console yet, because the browser tab died halfway through the pricing wizard and I did not notice. The tool went live. It worked. It hit Indeed, came back with ten real postings, and told the caller: { "billed": { "event": "search jobs indeed", "count": 1 } } The platform recorded: { "chargedEventCounts": {} } So it served real data, over residential proxy that we pay for by the gigabyte, for free, while reporting that it had charged. Actor.charge does not throw when you hand it an event name that was never registered. It returns. My billing code had no try/catch because there was nothing to catch. The rule I now follow without exception: register the event in Console before deploying any tool that references it. Not after, not in the same sitting, tools array and GET /v2/acts/{id} pricingInfos .There is a hardening step I have not built yet and probably should: have the server refuse to register a tool whose billing event is missing from the Actor's own pricingInfos at startup. That turns a silent revenue leak into a loud boot failure. Flagging it as a gap rather than pretending I solved it. prefill is not default This one did not cost money. It quietly broke an Actor for every programmatic caller for an unknown length of time, which is arguably worse. My Google Trends Actor declared its proxy input like this: { "proxyConfiguration": { "type": "object", "prefill": { "useApifyProxy": true, "apifyProxyGroups": "RESIDENTIAL" } } } prefill populates the form in the Console. That is all it does, and the input schema specification https://docs.apify.com/platform/actors/development/actor-definition/input-schema/specification/v1 says so plainly once you go and read it. Open the Actor in a browser, the field is filled in, you hit run, it works. Call the same Actor from the API, or from a schedule, or from an MCP server, and proxyConfiguration arrives as undefined . No residential proxy, against an endpoint that returns 429 to datacenter IPs on sight. Every human test passed. Every programmatic caller failed, including my own MCP server calling my own Actor. The fix is a real default , plus a fallback in code so the schema and the runtime cannot drift apart: js const proxy = input.proxyConfiguration ?? { useApifyProxy: true, apifyProxyGroups: 'RESIDENTIAL' , }; I then swept all 96 Actors on the account for the same pattern. Sixteen had it. Fifteen were harmless because the code already had a fallback. One was a live gap on an Actor doing 146 runs a month. If you built your Actors by cloning a template, and most of us did, go and check. It is a ten minute grep. Two of my Actors showed "17 of 17 runs succeeded" for weeks while returning zero rows to everybody. Not failing. Succeeding. The target had changed its markup, the parser matched nothing, the Actor wrote its summary row and exited zero. Every dashboard was green. The 30-day success rate on the Store listing said 100%. Billing was correct throughout, nobody was charged, which is the only reason this was not a refund event. But the listing was selling something that did not work, and the stat I would have pointed at to prove it worked was the stat hiding the problem. Run status tells you your process exited cleanly. It tells you nothing about whether a customer got data. They are different questions and only one of Every one of these was found the same way. After a call, compare what your own code thinks it billed against what the platform actually recorded: js const run = await client.run runId .get ; // NOT the runs-list endpoint const platform = run.chargedEventCounts ?? {}; const mine = { event : 1 }; if JSON.stringify platform == JSON.stringify mine { console.error 'BILLING MISMATCH', { runId, mine, platform } ; } One detail that wasted an afternoon: GET /v2/acts/{id}/runs returns an abbreviated record with chargedEventCounts: {} and pricingInfo: null even when charges definitely exist. Only the get-run endpoint https://docs.apify.com/api/v2/actor-run-get , GET /v2/actor-runs/{runId} , has the real numbers. Do not conclude "nothing was charged" from the list endpoint. I did, twice. That comparison is the only thing that detects this whole class of bug. Unit tests will not, because your code is behaving exactly as written. Manual Console testing will not, because the Console is the one caller that gets the prefills. Watching your revenue will not, because the failure mode is revenue that looks fine. I now run it on every tool on every server as part of the release check. It has caught something every single time I have added tools. Unrelated to billing but it will bite you in the same week: Apify Standby returns 504 on any response over five minutes , whatever timeout you set yourself. I had a composite tool that chains an Amazon product search into a review pull. Amazon runs 45 to 110 seconds for a search and about 107 for reviews, so I set an internal budget of 420 seconds to be safe. The gateway killed it before my own tool ever finished. 280 seconds fits and works. If your Actor genuinely runs for 20 minutes, it cannot be a synchronous tool call at all. It has to return a run ID immediately and let the agent collect the result later. Worth designing for on day one rather than discovering at 420 seconds. Write the invariant down before you write the billing code. Mine is one sentence and it has paid for itself repeatedly, mostly by making it obvious which of my assumptions were wrong. Then assume you have broken it, and go and check against the platform's own numbers rather than your own. Four for four, that is where I found it. If you want to test your own Actor for this today, there are three things worth doing in order. Call one tool you already ship with an input you know returns nothing, then read the run back from the get-run endpoint and compare chargedEventCounts against what your code says it billed. Grep your input schemas for prefill on any field that the runtime actually depends on, and give each one a real default . Then take any Actor sitting at a perfect 30-day success rate and check its delivered rows per run, because that is the number the success rate is hiding. Each one is a short job. All three found something on my account. The Mine Works builds and maintains 82 web scrapers on the Apify Store, with seven MCP servers over them so AI agents can call the data as tools. All of it runs on Apify pay-per-event billing, which is why one rule sits above the rest: a tool call that returns nothing must never fire a billable event. Most of what gets published here is a record of how that rule got broken and what the fix looked like, with the run counts and the failure modes left in rather than tidied away.