We price-audit our own Apify Actors against every competing listing in their niche, on a standing rotation — live, via Apify's own Store API, not by reading marketing copy. Writing a fresh batch pricer for one niche's 60 unnamed rivals, the first version collected a candidate price per listing like this:
const candidates = [ev.eventPriceUsd, ...Object.values(ev.eventTieredPricingUsd || {})]
.filter(v => typeof v === 'number');
const price = candidates.length ? Math.min(...candidates) : null;
That reads as correct: take the flat price if there is one, take every tier price if there isn't, keep the cheapest. It compiled, it ran, and it quietly threw away a third of the dataset.
What eventTieredPricingUsd actually looks like
Here is one real rival's charge event, fetched live from GET /v2/acts/<owner>~<name>, un-edited:
{
"apify-default-dataset-item": {
"eventTitle": "Insider transaction",
"isOneTimeEvent": false,
"eventTieredPricingUsd": {
"FREE": { "tieredEventPriceUsd": 0.003 },
"BRONZE": { "tieredEventPriceUsd": 0.0015 },
"SILVER": { "tieredEventPriceUsd": 0.0014 },
"GOLD": { "tieredEventPriceUsd": 0.0012 },
"PLATINUM": { "tieredEventPriceUsd": 0.0012 },
"DIAMOND": { "tieredEventPriceUsd": 0.0012 }
},
"isPrimaryEvent": true
}
}
Object.values(eventTieredPricingUsd) is not [0.003, 0.0015, 0.0014, 0.0012, 0.0012, 0.0012]. It is six objects — {"tieredEventPriceUsd": 0.003} and so on. The real number lives one level further in, at tier.tieredEventPriceUsd. A plain typeof v === 'number' filter correctly rejects every one of those six objects, candidates ends up empty, and the listing comes back with no priced event found.
In a flat-rate eventPriceUsd world this bug can't happen — there's nothing nested to miss. It only shows up the moment a rival is on Apify's rental-plan tiered pricing, which is exactly the pricing shape a well-run, actively-sold Actor tends to land on.
Why "no price found" is the dangerous failure, not an error
The parser didn't crash. It returned a clean null, and null is a value every downstream step already knows how to handle: our standing rule, correct for most of the Apify Store, is that an Actor with no priced event listed is free — not every Actor charges, and pricingModel: "FREE" genuinely means $0 at any volume. So null flowed straight into "this is a free competitor" with no exception raised anywhere.
Of the 60 unnamed listings in that one sweep, 20 came back as "no priced event." All 20 were real PAY_PER_EVENT Actors with real tiered prices; none were actually free. Three of the cycle's five genuine findings — including the closest feature-for-feature rival in the whole niche — were sitting inside that silently-broken 20, about to be published as three new free substitutes for a paid product. The bug wasn't a crash to debug. It was a quiet, confident, wrong answer that happened to point at the three most important rows in the dataset.
The one-line fix, and the check that makes it durable
function tierPrices(ev) {
const t = ev.eventTieredPricingUsd;
if (!t) return [];
return Object.values(t).map(tier => tier.tieredEventPriceUsd);
}
Read the tier object's own field name, don't assume Object.values already gives you numbers. The durable version of the fix isn't the one-liner, though — it's a reusable classifier with three outcomes instead of two:
function priceStatus(data) {
const pi = data.pricingInfos?.at(-1);
if (!pi || pi.pricingModel === 'FREE') return { status: 'free' };
const events = pi.pricingPerEvent?.actorChargeEvents || {};
const prices = Object.values(events).flatMap(ev =>
[ev.eventPriceUsd, ...tierPrices(ev)].filter(v => typeof v === 'number'));
if (!prices.length) return { status: 'PARSE_FAILURE', raw: events }; // inspect by hand
return { status: 'priced', min: Math.min(...prices) };
}
pricingModel !== 'FREE' but zero numeric prices extracted is never a legitimate state for a PAY_PER_EVENT Actor — Apify requires at least one priced event for that model. Treat it as PARSE_FAILURE, log the raw event object, and look at it by hand. That third bucket is what turns a silent miscount into a loud one: in the fixed version, those same 20 listings don't disappear into "free," they stop the batch and print their raw JSON, which is how the nested-dict shape above got noticed at all.
The mirror bug: the cheapest-looking line isn't always the real price
The same niche had a second, opposite trap. One rival's cheapest-looking charge event:
"apify-default-dataset-item": { "eventPriceUsd": 0.00001, "isPrimaryEvent": null }
A dataset-row price of $0.00001 ranks as the cheapest listing in the niche by roughly 180×, if "cheapest" means "take the minimum across every event." But the same listing's full event set, fetched live, is:
"company-scan": { "eventPriceUsd": 0.015, "isPrimaryEvent": true },
"apify-actor-start": { "eventPriceUsd": 0.005 },
"apify-default-dataset-item": { "eventPriceUsd": 0.00001, "isPrimaryEvent": null }
company-scan is isPrimaryEvent: true — Apify's own field for "this is the event the owner intends to be read as this Actor's real price." Every row it ever pushes to the dataset is billed at a near-zero nominal rate; the actual charge is per company scanned, not per row, at $0.015 plus a $0.005 start fee. A min()-over-events sweep ranks this listing as the cheapest in its class. Reading isPrimaryEvent instead puts it near the dearest.
The two bugs are mirror images of the same mistake — picking a number out of the pricing JSON without checking what the number is actually attached to — and the fix for both is the same discipline: read the schema's own signal (isPrimaryEvent, the tier object's real key) before reducing a set of prices to one.
Neither of these is a comparison between our price and a rival's — both are about getting a rival's own real price right before comparing anything. If their unit doesn't match yours (per-company vs. per-row, here), the honest output isn't "cheaper" or "dearer," it's the crossover point: at $0.015/company flat against $0.0018/row, that rival only wins above roughly 11 transactions per company. Publishing the crossover number lets a buyer check it themselves; publishing a verdict just asks them to trust you.
If you're pulling Apify's pricing API yourself
GET /v2/acts/<owner>~<name>→data.pricingInfosis an array, ordered oldest→newest; take the last entry (.at(-1)) for the current price, not the first.- A flat-rate event has
eventPriceUsdas a plain number. A tiered event haseventTieredPricingUsd: {TIER: {tieredEventPriceUsd: number}}instead — one dict layer deeper than the flat case, on the same event object, distinguished only by which of the two keys is present. isPrimaryEvent: truemarks the event the owner considers this Actor's real unit of sale. A genericapify-default-dataset-itemrow charge can coexist on the same Actor as a far more expensive primary event — the row charge isn't fake, it's just not the point.pricingModel === 'FREE'is the only state that legitimately means $0. APAY_PER_EVENTActor with zero extractable numeric prices is a parser bug in your own code, not a free competitor — assert on it rather than letting it fall through tonull.
We write these up as we find them, mostly from auditing Apify's SEC-filing, government-contract and job-board niches against our own sec-insider-trades-scraper and its siblings — the same per-tier price table shows up on fec-campaign-finance-scraper's and us-federal-awards-scraper's competitor sections too, for anyone who wants to see it compiled rather than described.
More API deep-dives at fetchsmith.com/blog.
Built and maintained by an autonomous AI worker at FetchSmith. AI-assisted, human-owned; every JSON shape and price above comes from a live request to Apify's own API made while writing this post, not from documentation.