Quick answer
A plain curl at a nonexistent Snapchat username — no browser impersonation, no proxy — returns a bare 404. The same username, fetched through the path the Snapchat Profile Scraper actually ships (impersonated Chrome/Firefox TLS, US-proxied), returns 200 with a doubly nested __NEXT_DATA__ payload whose real signal is buried at pageProps.pageProps.pageMetadata.pageType == "NOT_FOUND". Those are two different documents describing the same fact. Our classifier was originally written against the curl-shaped probe and called the production response MALFORMED instead of NOT_FOUND — a clean "this user doesn't exist" was misreported as a parse failure.
Why would the same missing username produce two different response shapes?
Because "the page your probe sees" and "the page your scraper sees" are only the same document if you fetched them the same way, and a bare curl and an impersonated, proxied browser session are not the same way. Snapchat serves a lighter, flatter error response to unauthenticated, unfingerprinted requests — a 404 status with a small body — and serves the full Next.js application shell, __NEXT_DATA__ and all, to requests that look like a real browser hitting the page. Once you're getting the full shell, the "not found" state isn't a status code anymore, it's a field three levels deep inside a JSON payload that otherwise looks exactly like a live profile's payload structure. If your test fixtures were captured with a convenient curl command during development and your classifier logic was written against that shape, it will correctly handle a probe and incorrectly handle the thing you actually ship, because production traffic never takes the path you tested.
The nesting itself is a small, specific trap: pageProps appears twice, not once. The outer pageProps is the Next.js page wrapper; the inner pageProps is where Snapchat puts the actual page data, including pageMetadata.pageType. Reading data.pageProps.pageMetadata instead of data.pageProps.pageProps.pageMetadata gets you None on every single request, live profile or not — which looks exactly like a malformed-page symptom, not a missing-key symptom, and sends you debugging the wrong layer.
What's the second finding, on top of the fixture mismatch?
userProfile in that same payload is a discriminated union keyed on a $case field, not a single fixed shape. The obvious-looking implementation assumes publicProfileInfo is always present and reads it directly — which works for the common case and silently drops every profile whose $case resolves to something else, private accounts and non-standard profile types included. Switching on $case explicitly, the way a discriminated union is meant to be consumed, is what keeps those profiles correctly classified (and reported) instead of vanishing from the run with no trace they were ever requested.
Capture your fixtures from the path you actually ship — impersonation, proxy, headers, all of it — not from whatever curl command was fastest to type while developing. A probe and a production request can return structurally different documents for the identical fact, and a classifier trained on the wrong one fails exactly where it matters most: distinguishing "doesn't exist" from "couldn't be read."
What the Actor gives you
One row per resolved username: is_public_profile, display_name, bio, website_url, subscriber_count, profile_picture_url, snapcode_image_url, has_active_story, story_snap_count, and story_snaps (per-snap media_url, media_preview_url, media_type, posted_at) when the account currently has a live story. page_title and profile_url are included for traceability. A private, removed, or nonexistent username is a per-item skip reported in the run's status message — it never fails the whole run.
Honest limitations 🚧
v1 covers the profile card and current story only — no lenses, no curated/Spotlight highlights, no tagged-tab search, no pagination. story_snaps[].media_url and media_preview_url are Snapchat CDN links that expire — Snapchat stories are ephemeral by design, so treat story media as a snapshot at scrape time, not a durable asset you can fetch later. No authenticated views, DMs, or friend-graph data — public profile pages only.
FAQ
Why did a username that clearly exists come back as not found in my own quick test?
Check how you tested it. A bare, unimpersonated request to Snapchat's profile URL can return a different response shape than the one this Actor uses in production — the production path is impersonated and proxied specifically because that's the path that reliably resolves real profiles.
Do I need a Snapchat account?
No — this reads only the public profile page at snapchat.com/@<username>, no login, no API key.
Can I rely on story_snaps media URLs after the run finishes?
Not indefinitely. They're Snapchat CDN links tied to ephemeral story content and will expire — download or re-host anything you need to keep before the story itself expires on Snapchat's side.
$0.20 per run plus ~$0.003 per resolved public profile — ~$3.00 per 1,000 profiles.
→ Snapchat Profile Scraper on Apify
Built by Devil Scrapes. We rotate browser fingerprints, pin the proxy exit the payload actually needs, and retry with backoff — and we test our classifiers against the page our own scraper sees, not the page a quick curl sees.
Top comments (0)