Quick answer: local recon cleared PeoplePerHour 4 times in a row, across four different browser-fingerprint profiles, all 200 OK. Minutes later, the first cloud run against the exact same build got HTTP 202 from every single listing URL. Not blocked, not 403, not a captcha — a bare {"useApifyProxy": true} in the input schema resolves to a datacenter proxy, and PeoplePerHour hands datacenter IPs a 202 that never turns into real data. Pin the proxy to RESIDENTIAL and GB, same build, and the run came back with 20 real rows.
Why did local recon pass and the cloud run fail on the same build?
Because local recon and a default Apify proxy config aren't the same request. Our pre-build recon ran four probes — chrome131, chrome124, firefox133, firefox147 — from wherever local recon actually egresses from, and all four came back clean 200s with no anti-bot signal at all. That's a real result, and it's also not the network path a customer's run takes by default.
{"useApifyProxy": true} with nothing else specified is a valid, schema-passing input — and it resolves to Apify's shared datacenter pool. PeoplePerHour answers requests from that pool with HTTP 202 Accepted on every listing URL: a status code that isn't an error, isn't a block, and isn't the page either. It's the shape of failure that's easy to miss, because nothing about it looks like a block in a log line — it looks like the request "worked."
This is not a new mistake for us, either. It's the third time in a few months a bare useApifyProxy: true has quietly meant datacenter and quietly cost a target: the same gap hit us on willhaben in June and patreon in September. We know the pattern by name now — a schema field that sounds like "use the proxy" instead of specifying which proxy tier actually clears the target.
Why pin the country too, and not just the tier?
Because a wrong country on a residential exit doesn't fail — it lies. PeoplePerHour is a UK-facing marketplace, and a geo-random residential IP (US, DE, wherever the pool happens to hand you) can still return a 200 OK full of listings. They're just the wrong region's listings, or a differently-localized version of the page, served with total confidence and no error to catch. A code path that only checks for a failed request will never see this — the request didn't fail.
So the fix pins both halves deliberately: apifyProxyGroups: ["RESIDENTIAL"] for the tier PeoplePerHour actually accepts, and apifyProxyCountry: "GB" so every exit is a UK one instead of a plausible-looking gamble. Neither alone would have been enough — residential-but-wrong-country would have shipped a dataset of subtly wrong rows with a clean run status on top.
Was residential worth the extra publish scrutiny?
Yes, and we checked that it was actually worth it rather than assuming so. Requesting RESIDENTIAL moves an Actor into the anti-bot publish class, which means it owes two spaced green cloud runs instead of one before it can ship — a real cost in time. That's the wrong trade when a target doesn't actually need it: an earlier Actor in the fleet paid that exact publish tax for a residential request the target never required. Here it's justified by measurement, not assumption — the same build failed on datacenter and passed on residential, in the same session, with everything else held constant.
What this Actor actually reads
PeoplePerHour's freelance-jobs board doesn't call a separate API for its listing data — the whole page renders from a Redux state blob (window.PPHReact.initialState) embedded directly in the HTML response. This Actor decodes that blob instead of scraping rendered markup, and walks ?page=N to collect more than one page per query. Multiple {keyword, categorySlug} queries fan out in the same run, each with its own fault isolation — one blocked or malformed query never aborts the others.
What you get per row
| Field | Notes |
|---|---|
job_id / title / url
|
Listing identity and canonical URL |
category / sub_category (+ slugs) |
PeoplePerHour's own taxonomy |
project_type |
fixed_price or hourly, verbatim |
budget_amount / budget_currency / budget_converted_usd
|
Native budget plus a USD conversion |
proposal_count / unread_proposal_count
|
Bidding activity |
client_name / client_country / client_city / client_url
|
Client details |
posted_at / expires_at / scraped_at
|
Listing lifecycle timestamps |
What does it actually cost?
Pay-Per-Event: $0.20 per run start + $0.003 per job scraped — $3.20 per 1,000 jobs. The start fee only fires the first time a query actually completes against the target, never at boot and never for a run that couldn't reach PeoplePerHour at all — a search that genuinely matches nothing still finishes SUCCEEDED and bills only the flat $0.20.
FAQ
Does this need a PeoplePerHour login?
No — the listing pages this Actor reads are fully public.
Why does this default to residential proxy instead of the cheaper option?
Because we measured it, not guessed it — a plain Apify Proxy request got HTTP 202 on every URL in the same build's first cloud run, and residential pinned to GB cleared it. Datacenter isn't a fallback here; it's a confirmed non-starter.
Can I run multiple searches in one job?
Yes — pass multiple entries in queries. Each is scraped independently, so one bad query never blocks the others.
Why do some optional fields come back null?
PeoplePerHour doesn't populate every field on every listing (some jobs have no sub-category, for instance). Only the always-present fields are required for a row to validate.
😈 PeoplePerHour Freelance Jobs Scraper reads PeoplePerHour's own embedded Redux state and turns it into structured job rows — budget, project type, proposals, client details, canonical URL — across as many keyword/category queries as you throw at it in one run. We rotate browser fingerprints, pin the proxy tier and country the target actually needs, and isolate a bad query from the rest of your batch. $3.20 per 1,000 results.
Top comments (0)