# Apify pay-per-event billing: four ways I charged users for nothing

> Source: <https://dev.to/apify/apify-pay-per-event-billing-four-ways-i-charged-users-for-nothing-9m1>
> Published: 2026-08-31 18:58:08+00:00

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.
