Bluesky Profile Scraper
Pricing
from $2.55 / 1,000 profile delivereds
Bluesky Profile Scraper
Batch-fetch public Bluesky profiles by handle or DID via the official keyless AppView API — display name, bio, follower/follows/post counts, verification status, account creation date. No login, no token.
Pricing
from $2.55 / 1,000 profile delivereds
Rating
0.0
(0)
Developer
Tim Zinin
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
3 days ago
Last modified
Categories
Share
Bluesky Profile Scraper: Batch-Resolve Public Handles and DIDs Through Bluesky's Own Keyless AppView API
This Actor takes a list of Bluesky handles (bsky.app) or DIDs (did:plc:...) you already have and
returns each account's public profile — display name, bio, follower/follows/post counts, verification
status, account creation date — by calling app.bsky.actor.getProfiles, the same official, keyless
AppView endpoint Bluesky's own apps use to render a profile page. No login, no token, no browser, no
anti-bot workaround. One HTTP request resolves up to 25 identifiers at once.
This is deliberately narrow. It does not search Bluesky, does not read anyone's posts, does not walk a follower list, and does not monitor a feed. You give it the exact accounts you want checked, and it checks exactly those, batched.
The billable outcome is a delivered profile: one row, status:"ok", where Bluesky's own API
actually returned that identifier's data. Everything else is free: an identifier that doesn't resolve to
any account (status:"not_found"), a malformed identifier Bluesky's API itself rejects
(status:"error"), a value that was never sent to Bluesky at all because it looked like a URL rather
than a handle (status:"error"), and a batch this Actor genuinely could not fetch after retrying
(status:"truncated"). Two landmines in Bluesky's own API shape most of what this page explains — a
batch call that silently drops nonexistent identifiers instead of erroring on them, and a batch call
that fails entirely over one bad identifier in it — and this page shows you the real JSON this Actor
produced defending against both, taken from runs made while this page was being written. See Real happy,
partial and failure output.
What you get
- Real batched profile data, up to 25 identifiers per HTTP call, not one request per handle. A 100-identifier input becomes 4 calls, not 100.
- A nonexistent handle is never confused with "the request failed." Bluesky's own
getProfilesendpoint does not return an error for an identifier it cannot resolve — it simply omits it from the response array and still answersHTTP 200. A naive client reading array length against request length would lose that identifier's absence silently. This Actor diffs what you asked for against what actually came back, by identity (didorhandlematch, never array position), and manufactures an explicitstatus:"not_found"row for every identifier Bluesky's own response dropped — free, and never inferred from a shorter array. - One bad identifier in a batch does not cost its neighbors their data. A single syntactically
invalid handle or DID makes
getProfilesreject the entire call it's in with— even when every other identifier in that same call is perfectly valid. This Actor parses the exact offending value out of Bluesky's own error message text, reports it as its ownHTTP 400 InvalidRequeststatus:"error"row, and retries the rest of that same call without it — verified live below, with the real dataset rows from a mixed call. - No login, no token, no anti-bot workaround. Bluesky's own
robots.txtonpublic.api.bsky.appexplicitly invites crawling this API — quoted verbatim under Sources and rights. - A host you can trust, by construction, not by promise. Every handle or DID you supply is passed
only as an
actors=query-string value against one fixed, hardcoded host (public.api.bsky.app). Nothing in your input is ever interpreted as a URL or used to build a request target — a value that looks like a URL is rejected locally, before any network call, and this is demonstrated with a real dataset row below, not just asserted. - Charge-before-push billing, mutex-guarded against the concurrent-worker race a naive
check-then-push implementation would have at
maxConcurrency > 1— only astatus:"ok"row is ever charged;not_found,truncatedanderrorrows are always free and always still delivered, for transparency. - Runs on Apify: schedule it, call it from the API, the MCP server, or an n8n/agent workflow, export to JSON/CSV/Excel, or push results straight into your own pipeline.
Who uses it
- Lead-gen and influencer research teams batch-checking a list of Bluesky handles gathered from another source (a spreadsheet, a search, a referral list) for follower count, bio and account age, without opening each profile by hand.
- Competitive intelligence watching a defined list of competitor or industry-adjacent accounts for changes in bio, display name or follower count over time, one scheduled run at a time.
- CRM and data-enrichment pipelines attaching a social-profile snapshot to an existing lead or contact record that already carries a known Bluesky handle, turning "we have a handle on file" into "we have their current follower count, bio and verification status on file."
- Developers and agent/workflow builders who want one flat, machine-checkable row per identifier —
found,status,error— that a script, an n8n node, or an LLM agent can branch on directly, without learning Bluesky's AT Protocol response shape or itsactors=batch semantics by hand. - Not for discovering accounts by keyword or topic. Bluesky's own full-text post search
(
app.bsky.feed.searchPosts) requires an auth token this Actor deliberately does not use — see Limits. You bring the identifiers; this Actor resolves them.

How to run
- Populate Handles or DIDs with one identifier per line — a Bluesky handle (
bsky.app,jay.bsky.team) or a DID (did:plc:...). Up to 100 per run; duplicates are silently deduplicated before any request is made (case-insensitive for handles, exact for DIDs). - Leave Max concurrency at its default of
5unless you have a specific reason to raise it (up to10) or lower it (down to1) — see Operating guide for why the default is conservative. - Start the run from the Console, the Apify API, a scheduled Task, or an MCP client. The prefilled
input (
["bsky.app"]) is Bluesky's own official account — a default run with no edits produces one real, non-empty row at essentially no cost, so you can see the actual data shape before committing a longer list. - Read the Dataset: one row per identifier you supplied, always in the same 16-field shape regardless
of outcome. Check
statusbefore anything else —"ok"is the one billed outcome;"not_found","truncated"and"error"are always free. - A DID is more stable than a handle if you are tracking the same account across weeks — a handle can be changed by its owner at any time, a DID cannot.
- Check this run's key-value store record
OUTPUTfor a one-time run summary — requested/delivered/ paid/free/failed/incomplete counts, replay safety and any fatal error — written once at the end of the run. The Dataset itself remains the complete, row-by-row authoritative record;OUTPUTis a roll-up of it.
Pricing
$0.003 per Actor start, plus $0.003 per delivered profile. The primary pricing noun on this page, in
the Dataset, and in this Actor's platform pricing record is the same throughout: the delivered
profile — one row where Bluesky's own API actually returned that identifier's data (status:"ok").
100 delivered profiles cost about $0.303 ($0.003 start + 100 × $0.003).
Every other row is free: not_found (confirmed absent via the diff described above), truncated (a
batch this Actor genuinely could not fetch after retrying), and error (a malformed identifier Bluesky
rejected, or a value this Actor refused to send at all because it looked like a URL). A run of 100
handles where 30 don't exist is billed for the up-to-70 it actually delivered, plus the one fixed start
fee — never for the 30 it honestly reports as absent.
Both free outcomes are enforced in code, not left to a documentation promise: the charge and the dataset
write happen together, inside one mutex-protected step (lib/billing.js), and a row is never pushed to
the paid channel unless its status is already "ok" at that point. There is no code path in this build
where a not_found, truncated or error row is billed.
This Actor's own external data cost is close to zero — one batched HTTP GET per up to 25 identifiers, no proxy, no LLM, no paid third-party API — so the per-profile price reflects the batching, the silent-empty defense and the batch-poisoning isolation described on this page, not a resold data bill.
On Apify's own billing record these are metered as two separate platform events — apify-actor-start
for the run fee, and result-found for each delivered profile — the same two lines you will see on
your invoice.
Input contract
Exactly one field is required.
handles— array of strings, 1 to 100 items, required. Each item is a Bluesky handle (e.g.bsky.app) or a DID (e.g.did:plc:z72i7hdynmk6r22z27h6tvur). A leading@is stripped automatically. Any value containing whitespace or://is rejected locally, before any network call — it cannot be a valid AT-identifier, so it is reported as its own freestatus:"error"row rather than sent to Bluesky's API. Values are deduplicated (case-insensitive for handles, exact-match for DIDs) before any request is made.maxConcurrency— integer, 1 to 10, default5. How many batch calls (each covering up to 25 identifiers) this run makes in parallel. See Operating guide for why the default is conservative and what it does and does not affect.
Two runnable input examples, built directly from .actor/input_schema.json — these are input
illustrations, not run output; the Real happy, partial and failure output section below shows the actual
Dataset rows these kinds of inputs produced, from real runs of this exact build.
{"handles": ["bsky.app"],"maxConcurrency": 5}
(This is the Actor's own prefilled default input — Bluesky's official account, run at the default concurrency. This is what a buyer sees and can run with zero edits.)
{"handles": ["bsky.app","jay.bsky.team","this-handle-should-not-exist-zzz123.bsky.social"],"maxConcurrency": 1}
(Mixes two real, live accounts with one identifier deliberately constructed not to exist, in the same
batch call — to show that a buyer does not need to pre-filter their own list before sending it in; the
nonexistent one comes back as a free, honest not_found row alongside the two real ones, without costing
either of them their data. This is the exact input behind the mixed-batch example below.)
Real happy, partial and failure output
Every row in the Dataset shares one flat, 16-field shape regardless of outcome — input, found, did,
handle, displayName, description, avatarUrl, followersCount, followsCount, postsCount,
createdAt, indexedAt, verified, status, error, checkedAt. Every JSON block below is copied
verbatim from an actual local run of this exact main.js build (node main.js with
CRAWLEE_STORAGE_DIR set to a local storage folder), executed while this page was being written,
against the live Bluesky API. Nothing here is invented or reconstructed from the schema — every field
value below is what the Dataset actually contained after that run finished.
Happy path — two real accounts in one mixed batch call
The input above (bsky.app, jay.bsky.team, and a nonexistent handle) was sent as a single call to
app.bsky.actor.getProfiles. Bluesky's own AppView answered with two real profiles and silently omitted
the third — exactly the landmine this Actor exists to defend against (see Evidence and boundaries). Row
one, Bluesky's own official account:
{"input": "bsky.app","found": true,"did": "did:plc:z72i7hdynmk6r22z27h6tvur","handle": "bsky.app","displayName": "Bluesky","description": "official Bluesky account (check username👆)\n\nBugs, feature requests, feedback: support@bsky.app","avatarUrl": "https://cdn.bsky.app/img/avatar/plain/did:plc:z72i7hdynmk6r22z27h6tvur/bafkreihwihm6kpd6zuwhhlro75p5qks5qtrcu55jp3gddbfjsieiv7wuka","followersCount": 34558831,"followsCount": 11,"postsCount": 806,"createdAt": "2023-04-12T04:53:57.057Z","indexedAt": "2025-10-27T21:05:26.152Z","verified": "none","status": "ok","error": null,"checkedAt": "2026-08-17T20:32:26.465Z"}
Row two, in the same call, a verified account with a different verified value observed live:
{"input": "jay.bsky.team","found": true,"did": "did:plc:oky5czdrnfjpqslsw2a5iclo","handle": "jay.bsky.team","displayName": "Jay 🦋","description": "Founder & Chief Innovation Officer @ Bluesky\n\nWorking on @attie.ai\n\n🌱 🪴 🌳","avatarUrl": "https://cdn.bsky.app/img/avatar/plain/did:plc:oky5czdrnfjpqslsw2a5iclo/bafkreihxtnc37g7jqdcgidtkknwuswtjiijcdnc6cx4imc4oq33cnsc5da","followersCount": 595133,"followsCount": 3974,"postsCount": 4110,"createdAt": "2022-11-17T06:31:40.296Z","indexedAt": "2026-03-29T21:16:33.460Z","verified": "valid","status": "ok","error": null,"checkedAt": "2026-08-17T20:32:26.465Z"}
Both rows are billed, delivered profiles — status:"ok", real counters, real bio text, real avatar URLs.
Note indexedAt is not "when this Actor checked" (that's checkedAt) — it's when Bluesky's own index
last refreshed that specific profile, and the two can be months apart, as jay.bsky.team's row shows.
Free path — the same call's third identifier, honestly reported as absent
this-handle-should-not-exist-zzz123.bsky.social was in the exact same getProfiles call as the two
rows above. Bluesky's own API returned HTTP 200 with an array containing only the two real profiles —
this third identifier was simply not in it. This Actor diffed the requested list against the returned
array and manufactured this row itself, rather than silently ending up with a 2-row dataset from a
3-identifier request:
{"input": "this-handle-should-not-exist-zzz123.bsky.social","found": false,"did": null,"handle": null,"displayName": null,"description": null,"avatarUrl": null,"followersCount": null,"followsCount": null,"postsCount": null,"createdAt": null,"indexedAt": null,"verified": null,"status": "not_found","error": "not present in Bluesky's getProfiles response (nonexistent handle/DID, or account removed)","checkedAt": "2026-08-17T20:32:26.465Z"}
This row is free. The critical fact this run proves: three identifiers went in, three rows came out —
two "ok", one "not_found" — even though Bluesky's own response array itself was only two elements
long. A parser that read "array length 2" as "2 of 3 succeeded, silently drop the rest" would have lost
this row's existence entirely rather than reporting it.
Failure path — one malformed identifier isolated out of a valid neighbor's batch
A separate run sent bsky.app and totallynotavalidhandle in the same call. Bluesky's getProfiles
rejects a call outright with HTTP 400 InvalidRequest when even one of its actors= values is not
syntactically a valid handle or DID — this is not a per-item error, it is a whole-call failure. This
Actor parses the exact offending value out of Bluesky's own error message (
Invalid AT identifier (got "totallynotavalidhandle"){"input": "totallynotavalidhandle","found": false,"did": null,"handle": null,"displayName": null,"description": null,"avatarUrl": null,"followersCount": null,"followsCount": null,"postsCount": null,"createdAt": null,"indexedAt": null,"verified": null,"status": "error","error": "Bluesky rejected this identifier as malformed: totallynotavalidhandle","checkedAt": "2026-08-17T20:32:43.247Z"}
And in the same run, bsky.app — its valid neighbor in the same original call — still delivered its
real data on the retried, cleaned-up call:
{"input": "bsky.app","found": true,"did": "did:plc:z72i7hdynmk6r22z27h6tvur","handle": "bsky.app","displayName": "Bluesky","description": "official Bluesky account (check username👆)\n\nBugs, feature requests, feedback: support@bsky.app","avatarUrl": "https://cdn.bsky.app/img/avatar/plain/did:plc:z72i7hdynmk6r22z27h6tvur/bafkreihwihm6kpd6zuwhhlro75p5qks5qtrcu55jp3gddbfjsieiv7wuka","followersCount": 34558837,"followsCount": 11,"postsCount": 806,"createdAt": "2023-04-12T04:53:57.057Z","indexedAt": "2025-10-27T21:05:26.152Z","verified": "none","status": "ok","error": null,"checkedAt": "2026-08-17T20:32:43.247Z"}
The error row is free; the bsky.app row is billed. Note followersCount moved from 34558831 to
34558837 between the two runs, roughly seventeen seconds apart — a live, moving counter, not a cached
snapshot repeated on every call.
Local-guard path — a URL supplied instead of a handle, no network call made
A separate run sent https://evil.example/@fake as an identifier. This Actor's own input validation
rejects anything containing :// or whitespace before any request is built — the value is not sent to
Bluesky's API at all, and it is never used to construct a request host:
{"input": "https://evil.example/@fake","found": false,"did": null,"handle": null,"displayName": null,"description": null,"avatarUrl": null,"followersCount": null,"followsCount": null,"postsCount": null,"createdAt": null,"indexedAt": null,"verified": null,"status": "error","error": "not a valid Bluesky handle or DID (contains a URL or whitespace) — no request was made","checkedAt": "2026-08-17T20:32:50.254Z"}
This row is also free. The error text says "no request was made" because none was — the request this
Actor ever sends always targets the one fixed host public.api.bsky.app, with your identifier passed
only as an actors= query value, never as a URL or a host.
A path this page discloses but did not reproduce live: status:"truncated"
This Actor's code marks every remaining identifier in an up-to-25 chunk as status:"truncated" when that
chunk's own network call fails after retries (a non-retryable HTTP status, or MAX_NETWORK_RETRIES
exhausted on a retryable one) or returns unparsable JSON. No run made while writing this page triggered
this path — Bluesky's API answered cleanly on every call attempted. It is documented here from the code
(main.js, fetchProfilesChunk), not from a live example, and that distinction is intentional: see the
honest list under Limits.
Field dictionary
| Field | Meaning | Important boundary |
|---|---|---|
input | Exactly the identifier you supplied, unmodified except a stripped leading @ | Echoed even on a locally-rejected or malformed row — lets you match every input back to its outcome |
found | Whether Bluesky's API actually returned data for this identifier | true only alongside status:"ok" |
did | The account's permanent decentralized identifier | null on every non-"ok" row; more stable than handle — does not change if the owner renames their handle |
handle | The account's current handle | Can change; did cannot — prefer did for tracking the same account across weeks |
displayName / description | Profile name and bio, as set by the account owner | Free text; not every field is filled on every account — description in particular is often empty on new profiles, which is an honest answer from the source, not a scraping gap |
avatarUrl | Avatar image URL | null when the source has none set |
followersCount / followsCount / postsCount | Live counters at the moment of the check | Not cached snapshots — see the two consecutive bsky.app rows above, whose followersCount moved between calls seconds apart |
createdAt | Account creation date | null on every non-"ok" row |
indexedAt | When Bluesky's own index last refreshed this profile | Not the same as checkedAt — can be months older, as jay.bsky.team's row above shows |
verified | The account's verification status, as Bluesky's response reports it | Observed live values: "none", "valid". null when the source's response carries no verification field at all — never guessed |
status | The billing and outcome boundary | "ok" (delivered, billed) / "not_found" (confirmed absent via diff — free) / "truncated" (batch fetch failed after retries — free) / "error" (malformed identifier, or locally rejected before any request — free) |
error | Human-readable reason, populated on every non-"ok" row | null only on status:"ok" |
checkedAt | ISO-8601 timestamp of this row's outcome | Per-row, not per-run — rows from the same run can carry slightly different timestamps under concurrency |
Evidence and boundaries
Why this source, keyless, no login. public.api.bsky.app/robots.txt states plainly: "Crawling the
public parts of the API is allowed," paired with Allow: /. This is an explicit invitation, not a
silent absence of a disallow rule — see Sources and rights for the full quote. getProfiles requires no
authentication for public profile data; this Actor sends none.
The silent-empty landmine, proven live, not asserted. A batch call to getProfiles that includes a
nonexistent handle or DID does not return an error entry for it — the whole call still answers
HTTP 200profiles[] array, which comes back shorter
than the request list. The mixed-batch run under Real happy, partial and failure output demonstrates this
directly: three identifiers requested in one call, two real accounts returned, and the third silently
missing from the array — this Actor's own diff (matching each requested identifier against the returned
array by did/handle identity, never by array position or length) is what turned that silent gap into
an explicit, honest status:"not_found" row rather than a dataset that quietly ended up two rows instead
of three.
The batch-poisoning landmine, proven live, not asserted. getProfiles fails the entire call with
HTTP 400 InvalidRequest if even one of its actors= values is not syntactically valid — confirmed live
in the run behind the failure-path example above: bsky.app alone succeeds, but paired with
totallynotavalidhandle in the same call, the whole call would fail with no data for either identifier
if left unhandled. This Actor's code parses the exact offending value out of Bluesky's own error message
text (the pattern Invalid AT identifier (got "X")), removes only that one value, and retries the
remaining call — the same run's bsky.app row above confirms the valid neighbor still delivered its real
data, undiminished by the identifier that had to be isolated out.
The 25-identifier chunk ceiling. main.js documents this as a verified hard API limit — a 26th
identifier in a single call gets HTTP 400 "array too big (maximum 25, got N)" — discovered live while
this Actor was being built, and encoded as CHUNK_SIZE = 25, the point at which any input longer than 25
is deterministically split into multiple sequential calls. This specific 25-vs-26 boundary was not
independently re-tested during the writing of this page — it is carried forward from the build's own
verification, not re-measured here; see Limits.
The SSRF/host-confusion guard, confirmed live, not just read from source. The local-guard example
above shows the real outcome of feeding this Actor a URL instead of a handle: status:"error", an
explicit "no request was made" message, and no network call to evil.example at all. Every identifier
you supply is passed only as a query-string value (actors=) against one hardcoded host
(public.api.bsky.app) — never interpreted as, or used to build, a request target. The DNS-rebinding and
private-address pinning logic in main.js (resolveVerifiedAddresses) exists as defense in depth around
that same fixed host, following the same pattern used across this hub's other Actors, per the factory's
own guardrail that this class of protection is never simplified away just because a given Actor's host is
already fixed.
What this page inherits from an earlier recon document, and honestly did not re-measure itself.
SPEC.md for this Actor draws several operational facts from an earlier, broader recon
(bluesky-scraper-monitor/SPEC.md) that were not independently re-verified while building or writing up
this narrower, profiles-only Actor:
- The ~30-second cache TTL. Bluesky's AppView is reported to serve profile responses through a CDN
with
cache-control: public, max-age=30in the earlier recon. This Actor's own testing session did not independently re-measure that header. Treat a profile snapshot from this Actor as accurate to within roughly half a minute of the check, not as a real-time read, and do not rely on it for sub-minute monitoring. - The concurrency guidance behind the
maxConcurrencydefault of 5. The earlier recon reports Bluesky's own operational guidance as "up to a handful of concurrent requests should be ok." This Actor's own build did not run an independent load test against that guidance — the conservative default of 5 (max 10) is carried forward as a sensible starting point, not a number this specific Actor proved itself. - The absence of keyless full-text search.
app.bsky.feed.searchPostsis reported in the earlier recon to returnHTTP 403without an auth token, on both a local network path and a datacenter path. This Actor does not attempt search at all — by design, not because of this specific finding — but the finding itself was not independently reproduced while building this Actor. - The
handle.invalidquirk on adjacent methods. The earlier recon separately found thatgetFollowers-style listing methods can surface accounts with a placeholderhandle.invalidvalue. This Actor never callsgetFollowersor any listing method — it only resolves identifiers you supply directly togetProfiles— and the acceptance goldens for this Actor were deliberately built only on real, valid handles/DIDs, not on an artifact of a method this Actor doesn't use.
None of the four items above are claims this specific build's own live testing session established from scratch; they are carried forward, attributed, and flagged as such rather than silently presented with the same confidence as the mixed-batch and batch-poisoning evidence gathered directly against this exact code.
Decision routing
status | Billable? | What it means |
|---|---|---|
"ok" | Yes | Bluesky's getProfiles response actually contained this identifier's data |
"not_found" | No | Confirmed absent by diffing the request against Bluesky's own response — not inferred from array length |
"truncated" | No | This identifier's chunk-level batch call failed after retries, or returned unparsable JSON — see the disclosed, not-live-reproduced note above |
"error" | No | Either Bluesky's API itself rejected this specific identifier as malformed (batch-poisoning path), or this Actor rejected it locally before any request (URL/whitespace guard) |
A workflow needs two rules: bill and use only status:"ok" rows; and never treat "not_found" as a
failure to retry — it is a genuine, checked answer from the source, not a gap in coverage.
Commercial playbooks
Influencer and lead-gen batch enrichment
A growth or partnerships team collects a list of Bluesky handles from another source — a hashtag search done by hand, a spreadsheet of prospects, a referral list — and runs them through this Actor in one batch to get follower count, bio and account age for every one, replacing opening each profile individually.
Competitor and industry-account watch
A competitive-intelligence function tracks a fixed list of competitor or industry-adjacent accounts, re-running this Actor on a schedule to see follower-count trajectory and bio/display-name changes over time, without building a bespoke Bluesky client.
CRM social-profile enrichment
A data-enrichment pipeline that already has a Bluesky handle on file for a contact (captured at signup, or matched by another tool) runs this Actor to attach current follower count, bio and verification status to that CRM record — turning a static handle field into a periodically refreshed profile snapshot.
Integration recipes
One-shot batch lookup
Feed a list of handles or DIDs collected from any source — a spreadsheet, a CRM export, a manual list —
into one run with maxConcurrency left at its default. Filter the resulting Dataset to status:"ok"
before loading it into a CRM or spreadsheet; not_found/truncated/error rows are the free record of
what did not resolve, not data to import.
Agent/MCP pattern
An agent that already has a specific handle or DID (from a search result, a page it read, or a
user-supplied value) passes it directly into handles, checks status before treating any row as
usable, and never assumes a not_found row means the check itself failed — it means Bluesky's own
response confirmed the identifier does not currently resolve to an account.
CRM/spreadsheet append pattern
Dataset rows are appended keyed by did when present (the stable identifier), or by input +
checkedAt for not_found/truncated/error rows that carry no did. null fields are preserved as
null, not coerced into an empty string, so a downstream query can tell "Bluesky has no bio for this
account" apart from "this field was never populated."
Operating guide
Choosing maxConcurrency. The default of 5 is a conservative starting point carried forward from
an earlier recon's report of Bluesky's own operational guidance, not a number this specific build load-
tested itself (see Evidence and boundaries). Raising it (up to 10) speeds up a run with many chunks of
25 identifiers; it does not change how many identifiers fit in one chunk — that ceiling (25) is fixed and
unaffected by concurrency.
Choosing handle vs did. A handle can be changed by its owner at any time; a DID cannot. If you
are tracking the same account across weeks or months for a recurring watch, prefer the did value from a
prior run's output over the handle you originally looked it up by.
Reading not_found correctly. This status means Bluesky's own API was asked and confirmed the
identifier does not currently resolve — via this Actor's own diff against the real response, not an
assumption from a short array. It is not the same thing as "the check failed" and should not be retried
automatically; a handle that was not_found yesterday and exists today simply changed state on
Bluesky's side.
Reading error vs truncated. "error" means either Bluesky's API rejected this specific
identifier as malformed (and your other identifiers in the same batch were unaffected — see the
batch-poisoning evidence above), or this Actor rejected it locally before any network call (a
URL/whitespace value). "truncated" means an entire up-to-25 chunk's own network call failed after
retries — a broader, rarer failure than a single bad identifier. Neither is billed.
Freshness expectations. Treat a delivered profile as accurate to within roughly the AppView's reported cache window (on the order of tens of seconds, per the earlier recon this Actor did not independently re-measure — see Evidence and boundaries), not as a real-time feed. For a one-time snapshot or a periodic re-check, this is not a limitation; for sub-minute monitoring, it is.
Related tools
Related tools for adjacent workflows in social platforms and regional directories.
| Actor | What it does |
|---|---|
| Substack Publication Scraper | Pair it in the social platforms and regional directories workflow: Read recent posts from any Substack publication's own domain — title, date, full HTML body, comment count... |
| YouTube Playlist Scraper | Pair it in the social platforms and regional directories workflow: Scrape any YouTube playlist by id or URL — including other people's public playlists, not just your own... |
| YouTube Channel Intel | Pair it in the social platforms and regional directories workflow: Public intel card for any YouTube channel: subscribers, total views, video count, join date, country, full... |
| YouTube Channel Videos List | Pair it in the social platforms and regional directories workflow: List up to 100 most recent uploads for any YouTube channel: videoId, title, view count, and publish age... |
| Telegram Export Intelligence | Pair it in the social platforms and regional directories workflow: Turn owner-authorized Telegram message exports into per-post reach context, evidence, confidence, review... |
FAQ
Why is a profile I know exists coming back not_found?
Bluesky's own API silently drops handles/DIDs it can't resolve from its response array — it does not
return an error for them. This Actor diffs what you asked for against what actually came back (by
identity, never by array position), so a typo'd handle, a deactivated account, or a handle change is
reported honestly instead of vanishing from your results. Double-check the exact spelling, or try the
account's did if you have it — a did never changes even if the handle does.
Does this Actor search Bluesky?
No. You supply the exact handles or DIDs you want, and this Actor resolves those. Bluesky's own full-text post search requires an auth token this Actor deliberately does not use — see Limits.
How fresh is the data?
Reported as roughly 30 seconds by an earlier, broader recon of the same AppView backend — this specific build did not independently re-measure that header (see Evidence and boundaries). Fine for a profile snapshot; not meant for sub-minute monitoring.
What happens if I put a URL in the handles field by mistake?
It's rejected locally, before any network request, as a free status:"error" row — see the local-guard
example above. Nothing is ever sent to a host other than public.api.bsky.app.
Can one malformed handle in my list break the whole run?
No — see the batch-poisoning evidence above. Bluesky's own API would fail the entire batch call over one
bad identifier if this Actor did nothing about it; instead, this Actor isolates the bad value into its
own free status:"error" row and retries the rest of that same batch, so a valid neighbor never loses
its data to one typo elsewhere in your list.
What this Actor is NOT.
It does not read posts, follower/following lists, or threads — only the profile record itself, for the handles/DIDs you provide. It does not search by keyword or topic. It does not monitor a feed in real time; each run is a point-in-time batch check.
Found a wrong result, or need a check this Actor doesn't run? Open an issue on this Actor's page.
Sources and rights
The endpoint this Actor calls is reachable by a plain, unauthenticated request, no browser session — the network's own public interface, which its robots.txt explicitly invites clients to use.
The only source. Bluesky's public AppView,
GET https://public.api.bsky.app/xrpc/app.bsky.actor.getProfilesactors= query
parameters, up to 25 per call. This is the same official, keyless endpoint Bluesky's own client
applications use to render a profile.
The permission, quoted verbatim. public.api.bsky.app/robots.txt:
"Crawling the public parts of the API is allowed"
paired with Allow: /. This is an explicit invitation to crawl this specific API, not merely the
absence of a disallow rule — checked directly against the live robots.txt text, not assumed.
No separate open-data licence was found for this specific API, and this page does not claim one.
Unlike a government open-data feed published under an explicit reuse licence (Open Government Licence,
CC0, etc.), Bluesky's robots.txt invitation to crawl is a statement about access, not a statement
about downstream redistribution rights over the data once fetched. This page makes no claim about what
licence, if any, governs republishing a fetched profile's bio text or display name in your own product —
that determination is yours to make, not this Actor's to certify. What this Actor does certify: every
field it returns is data the account owner already published on a public profile page, reachable by any
anonymous visitor.
Public profile data, not private data about third parties. Every field this Actor returns — display name, bio, avatar, counters, verification status, account creation date — is data the account's own owner chose to publish on their public Bluesky profile. This Actor does not access private accounts, does not require or use any authentication, and returns nothing beyond what an anonymous visitor to that profile's public page would already see.
Limits
- Reads public profile data only. No posts, no follower/following lists, no threads, no feed monitoring.
- No keyless full-text search.
app.bsky.feed.searchPostsrequires an auth token this Actor does not use — you must supply the exact handles or DIDs you want checked. - Batching tops out at 25 identifiers per HTTP call (a verified hard API ceiling, per the build's own testing — not independently re-tested during the writing of this page); a 100-identifier input becomes 4 sequential calls.
- The ~30-second AppView cache window, and the "handful of concurrent requests" concurrency guidance
behind the
maxConcurrencydefault, are both carried forward from an earlier, broader recon of the same backend and were not independently re-measured while building or writing up this specific, narrower Actor — see Evidence and boundaries for the full, itemized disclosure. status:"truncated"is a real, code-verified path (a chunk-level batch call failing after retries) but was not reproduced live during any run made while writing this page — Bluesky's API answered cleanly on every call attempted.verifiedreflects exactly what Bluesky's response reports ("none","valid", or absent →null) — this Actor never infers or guesses a verification state the source did not publish.descriptionand other free-text fields are frequently empty on newer or less-active accounts — an honest answer from the source, not a scraping gap.- This Actor makes no claim about downstream redistribution rights over fetched profile data beyond what is stated under Sources and rights — that determination is the buyer's to make.
Support boundary
Actor support covers this Actor's own input validation, the silent-empty diff defense, the
batch-poisoning isolation, the SSRF/host guard, and the Dataset schema described on this page — all
backed by a live, verified run referenced above. It does not cover whether a specific account is a good
research target, does not restore access if Bluesky changes this API's shape or its batch-size ceiling,
does not independently re-verify the cache-TTL or concurrency guidance inherited from an earlier recon
document (see Evidence and boundaries), and does not provide legal guidance on how fetched profile data
may be reused downstream. When reporting a problem, include the Actor run ID, the exact input value,
the status and error returned, and the checkedAt timestamp of the affected row.