DEV Community

themineworks for Apify

Posted on

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

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

I run 51 scrapers on Apify behind seven MCP servers. Agents call them as tools
over the Model Context Protocol:
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.

Prerequisites

None of this needs special tooling, but it assumes you are past these three
things:

  • An Apify account.
  • An Actor you have published with pay-per-event monetization enabled, and at least one billing event registered for it in Console.
  • Node with the 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.

Why Apify pay-per-event billing breaks quietly with agents

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.

Failure one: the summary row

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 }
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

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:

const billable = items.filter(
  (i) => i._type !== 'summary' && i.resolved !== false
);
if (billable.length === 0) return { found: false, billed: null };
Enter fullscreen mode Exit fullscreen mode

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
naive counter.

Failure two: 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 } }
Enter fullscreen mode Exit fullscreen mode

The platform recorded:

{ "chargedEventCounts": {} }
Enter fullscreen mode Exit fullscreen mode

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,
before. When I caught this I pulled the tool out of the tools array and
redeployed within minutes, then put it back only once GET /v2/acts/{id}
showed the event in 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.

Failure three: 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"] }
  }
}
Enter fullscreen mode Exit fullscreen mode

prefill populates the form in the Console. That is all it does, and the
input schema specification
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:

const proxy = input.proxyConfiguration ?? {
  useApifyProxy: true,
  apifyProxyGroups: ['RESIDENTIAL'],
};
Enter fullscreen mode Exit fullscreen mode

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.

Failure four: a successful run that delivers nothing

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
them matters. I now alert on delivered rows per run, not on run status, and I
would treat any Actor reporting a suspiciously perfect success rate as unproven
rather than reliable.

The one check that catches Apify pay-per-event billing bugs

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:

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 });
}
Enter fullscreen mode Exit fullscreen mode

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,
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.

One more thing, if you are building for agents

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.

Conclusion: what I would tell someone starting

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.

Author bio

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.

Top comments (0)