Add free-tier limits to your Apify actor
Push free users toward your paid plan without breaking the experience. This guide gives you a drop-in helper that caps results per run, throttles runs per day, and enforces a cooldown — while paid users and your own account bypass everything.
Use with an AI agent
Open this guide as a pre-filled prompt — or copy it for Claude Code, Cursor, Codex, or any other coding agent.
Why do this at all?
Apify lets you ship an actor with a free trial, but the SDK doesn't enforce trial limits for you. If you want free users to get a real taste — without footing the bill for unlimited runs — you need three caps:
- Results per run. A hard ceiling on the output dataset size.
- Runs per day. Stops free users from chaining N small runs to get an effectively-unlimited dataset.
- Cooldown between runs. Smooths out load and discourages scripted abuse.
Paid users (anyone on an Apify paid plan) are detected via the APIFY_USER_IS_PAYING environment variable that the Apify platform injects into every run. See how to tell if an Apify user is paying for the full rundown.
The helper
Drop this file alongside your main entry point. It uses a named key-value store called trial-stateso the counters persist across runs for the same user but don't pollute your default storage tab.
import { Actor } from 'apify';
// Tune these for your actor.
const TRIAL_MAX_RESULTS = 25; // results per run
const TRIAL_MAX_DAILY_RUNS = 3; // runs per UTC day
const TRIAL_MIN_INTERVAL_MS = 30 * 60 * 1000; // 30-minute cooldown
const STATE_STORE = 'trial-state';
const STATE_KEY = 'state';
/**
* Returns the max results to deliver on this run.
* 0 = unlimited (paid user, local dev, or your own account)
* 25 = free-tier cap is in effect
*
* Calls Actor.fail() and exits if the user hit the daily or cooldown cap.
*/
export async function enforceTrialLimits() {
// 1. Local dev — never limited
if (!Actor.isAtHome()) return 0;
// 2. Test runs from the Apify Console — never limited
if (process.env.APIFY_META_ORIGIN === 'TEST') return 0;
// 3. Paid Apify plan — unlimited
if (process.env.APIFY_USER_IS_PAYING === '1') return 0;
// 4. Your own account — replace with your APIFY_USER_ID
if (process.env.APIFY_USER_ID === 'YOUR_APIFY_USER_ID') return 0;
const store = await Actor.openKeyValueStore(STATE_STORE);
const state = (await store.getValue(STATE_KEY)) ?? {};
const now = Date.now();
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD (UTC)
const dailyRunCount = state.date === today ? state.dailyRunCount ?? 0 : 0;
const lastRunAt = state.lastRunAt ?? 0;
// Cooldown
const elapsed = now - lastRunAt;
if (lastRunAt && elapsed < TRIAL_MIN_INTERVAL_MS) {
const waitMin = Math.ceil((TRIAL_MIN_INTERVAL_MS - elapsed) / 60_000);
await Actor.fail(
`Free tier: please wait ${waitMin} more minute(s) before your next run, or upgrade to a paid plan.`,
);
return 0;
}
// Daily cap
if (dailyRunCount >= TRIAL_MAX_DAILY_RUNS) {
await Actor.fail(
`Free tier: ${TRIAL_MAX_DAILY_RUNS} runs per day — try again tomorrow, or upgrade to a paid plan.`,
);
return 0;
}
// Record this run *before* doing any real work.
await store.setValue(STATE_KEY, {
date: today,
dailyRunCount: dailyRunCount + 1,
lastRunAt: now,
});
Actor.log.info(
`Free tier: run ${dailyRunCount + 1}/${TRIAL_MAX_DAILY_RUNS} today, capped at ${TRIAL_MAX_RESULTS} results.`,
);
return TRIAL_MAX_RESULTS;
}
import os
import math
import time
from datetime import datetime, timezone
from apify import Actor
# Tune these for your actor.
TRIAL_MAX_RESULTS = 25 # results per run
TRIAL_MAX_DAILY_RUNS = 3 # runs per UTC day
TRIAL_MIN_INTERVAL_MS = 30 * 60 * 1000 # 30-minute cooldown
STATE_STORE = "trial-state"
STATE_KEY = "state"
async def enforce_trial_limits() -> int:
"""Return the max results to deliver on this run.
0 = unlimited (paid user, local dev, or your own account)
25 = free-tier cap is in effect
Calls Actor.fail() and exits if the user hit the daily or cooldown cap.
"""
# 1. Local dev — never limited
if not Actor.is_at_home():
return 0
# 2. Test runs from the Apify Console — never limited
if os.environ.get("APIFY_META_ORIGIN") == "TEST":
return 0
# 3. Paid Apify plan — unlimited
if os.environ.get("APIFY_USER_IS_PAYING") == "1":
return 0
# 4. Your own account — replace with your APIFY_USER_ID
if os.environ.get("APIFY_USER_ID") == "YOUR_APIFY_USER_ID":
return 0
store = await Actor.open_key_value_store(name=STATE_STORE)
state = await store.get_value(STATE_KEY) or {}
now = int(time.time() * 1000)
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
daily_run_count = state.get("dailyRunCount", 0) if state.get("date") == today else 0
last_run_at = state.get("lastRunAt", 0)
# Cooldown
elapsed = now - last_run_at
if last_run_at and elapsed < TRIAL_MIN_INTERVAL_MS:
wait_min = math.ceil((TRIAL_MIN_INTERVAL_MS - elapsed) / 60_000)
await Actor.fail(
status_message=(
f"Free tier: please wait {wait_min} more minute(s) before your "
"next run, or upgrade to a paid plan."
)
)
return 0
# Daily cap
if daily_run_count >= TRIAL_MAX_DAILY_RUNS:
await Actor.fail(
status_message=(
f"Free tier: {TRIAL_MAX_DAILY_RUNS} runs per day — try again "
"tomorrow, or upgrade to a paid plan."
)
)
return 0
# Record this run *before* doing any real work.
await store.set_value(STATE_KEY, {
"date": today,
"dailyRunCount": daily_run_count + 1,
"lastRunAt": now,
})
Actor.log.info(
f"Free tier: run {daily_run_count + 1}/{TRIAL_MAX_DAILY_RUNS} today, "
f"capped at {TRIAL_MAX_RESULTS} results."
)
return TRIAL_MAX_RESULTS
The return value is the contract: 0 means “no cap — use whatever the user asked for,” and a positive number is the free-tier ceiling you should clamp to.
Wire it into your actor
Call enforceTrialLimits() right afterActor.init() and use its return value as the effective max:
import { Actor } from 'apify';
import { enforceTrialLimits } from './trial-limits.js';
await Actor.init();
const input = (await Actor.getInput()) ?? {};
// Returns 0 for paid users (no cap) or 25 for free users.
const trialCap = await enforceTrialLimits();
const effectiveMax = trialCap > 0 ? trialCap : input.maxItems ?? 0;
// ... rest of your actor, using effectiveMax in place of input.maxItems
await Actor.exit();
from apify import Actor
from .trial_limits import enforce_trial_limits
async def main() -> None:
async with Actor:
input_data = await Actor.get_input() or {}
# Returns 0 for paid users (no cap) or 25 for free users.
trial_cap = await enforce_trial_limits()
effective_max = trial_cap if trial_cap > 0 else input_data.get("maxItems", 0)
# ... rest of your actor, using effective_max in place of input_data["maxItems"]
If your actor takes a list of input URLs and produces one result per URL, also slice the list so you don't pay upstream for data you'll never deliver:
// In a "1 URL → 1 result" mode, also trim the input list so you don't pay
// upstream costs for results you'll never deliver.
const startUrls = trialCap > 0 ? input.startUrls.slice(0, trialCap) : input.startUrls;# In a "1 URL → 1 result" mode, also trim the input list so you don't pay
# upstream costs for results you'll never deliver.
start_urls = input_data["startUrls"][:trial_cap] if trial_cap > 0 else input_data["startUrls"]For discovery-style actors that paginate until they hit a target, you don't need this — just push to the dataset until you reach effectiveMax.
Bypass for yourself
Apify injects APIFY_USER_ID on every run. Hardcoding your own ID lets you test the actor as if you were a regular user without burning your daily allowance. Find your ID at console.apify.com → Account → Integrations.
Better yet, lift the ID into an environment variable on your actor build so you don't leak it in source control.
The helper also bypasses limits when APIFY_META_ORIGIN is TEST — the origin Apify sets for runs launched from the Test tab in the Console. That way trying your actor from its listing never burns a daily slot or trips the cooldown. Apify reports several other origins too (WEB, API, SCHEDULER, CLI, STANDBY, …); TESTis the only one you'd typically want to exempt.
Gotchas worth knowing
- Failed runs still count as runs. When the cooldown or daily cap kicks in, Apify still records a run — but it costs almost nothing because you exit before any scraping happens.
- Crashes burn a daily slot. The helper saves
lastRunAtand increments the counter beforedoing work. If you'd rather only count successful runs, move thesetValuecall to the end of your actor — but be aware users can then crash on purpose to dodge the cap. - The day rolls over in UTC. A user in PT gets their reset at 4pm/5pm local. That's fine for most actors; if you care, swap
toISOString().slice(0, 10)for a timezone-aware format. - State is per actor + user. The
trial-statestore lives inside the run's storage scope, so every user gets an independent counter automatically. No shared global state to synchronize. - Make the limits visible to free users. Mention them in your README and your actor description. Surprised users leave bad reviews; informed users upgrade.
Where to go next
Once your limits are in place, run the Apify Pricing Calculator to translate “25 free results, then $X for 1,000” into the bundle pricing string that goes on your actor listing.
Spotted a bug, or want a guide on something else?
support@mail.apifyhub.com