Bluesky Profile Scraper avatar

Bluesky Profile Scraper

Pricing

from $2.55 / 1,000 profile delivereds

Go to Apify Store
Bluesky Profile Scraper

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

Tim Zinin

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

3 days ago

Last modified

Share

Bluesky Profile Scraper: Batch-Resolve Public Handles and DIDs Through Bluesky's Own Keyless AppView API

How this Actor turns your input into a decision 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 getProfiles endpoint does not return an error for an identifier it cannot resolve — it simply omits it from the response array and still answers HTTP 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 (did or handle match, never array position), and manufactures an explicit status:"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 getProfiles reject the entire call it's in with
    HTTP 400 InvalidRequest
    — 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 own status:"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.txt on public.api.bsky.app explicitly 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 a status:"ok" row is ever charged; not_found, truncated and error rows 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 its actors= 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.

Where this Actor sits in your workflow

How to run

  1. 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).
  2. Leave Max concurrency at its default of 5 unless you have a specific reason to raise it (up to 10) or lower it (down to 1) — see Operating guide for why the default is conservative.
  3. 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.
  4. Read the Dataset: one row per identifier you supplied, always in the same 16-field shape regardless of outcome. Check status before anything else — "ok" is the one billed outcome; "not_found", "truncated" and "error" are always free.
  5. 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.
  6. Check this run's key-value store record OUTPUT for 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; OUTPUT is 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 free status:"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, default 5. 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")
), reports it as its own row, and retries the rest of the call without it:

{
"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

FieldMeaningImportant boundary
inputExactly 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
foundWhether Bluesky's API actually returned data for this identifiertrue only alongside status:"ok"
didThe account's permanent decentralized identifiernull on every non-"ok" row; more stable than handle — does not change if the owner renames their handle
handleThe account's current handleCan change; did cannot — prefer did for tracking the same account across weeks
displayName / descriptionProfile name and bio, as set by the account ownerFree 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
avatarUrlAvatar image URLnull when the source has none set
followersCount / followsCount / postsCountLive counters at the moment of the checkNot cached snapshots — see the two consecutive bsky.app rows above, whose followersCount moved between calls seconds apart
createdAtAccount creation datenull on every non-"ok" row
indexedAtWhen Bluesky's own index last refreshed this profileNot the same as checkedAt — can be months older, as jay.bsky.team's row above shows
verifiedThe account's verification status, as Bluesky's response reports itObserved live values: "none", "valid". null when the source's response carries no verification field at all — never guessed
statusThe 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)
errorHuman-readable reason, populated on every non-"ok" rownull only on status:"ok"
checkedAtISO-8601 timestamp of this row's outcomePer-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 200
, and the missing identifier is simply absent from the profiles[] 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=30 in 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 maxConcurrency default 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.searchPosts is reported in the earlier recon to return HTTP 403 without 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.invalid quirk on adjacent methods. The earlier recon separately found that getFollowers-style listing methods can surface accounts with a placeholder handle.invalid value. This Actor never calls getFollowers or any listing method — it only resolves identifiers you supply directly to getProfiles — 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

statusBillable?What it means
"ok"YesBluesky's getProfiles response actually contained this identifier's data
"not_found"NoConfirmed absent by diffing the request against Bluesky's own response — not inferred from array length
"truncated"NoThis identifier's chunk-level batch call failed after retries, or returned unparsable JSON — see the disclosed, not-live-reproduced note above
"error"NoEither 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 for adjacent workflows in social platforms and regional directories.

ActorWhat it does
Substack Publication ScraperPair 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 ScraperPair 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 IntelPair 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 ListPair 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 IntelligencePair 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.getProfiles
, batched via repeated actors= 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.searchPosts requires 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 maxConcurrency default, 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.
  • verified reflects 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.
  • description and 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.