# Bluesky Profile Scraper (`zinin/bluesky-profile-scraper`) Actor

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.

- **URL**: https://apify.com/zinin/bluesky-profile-scraper.md
- **Developed by:** [Tim Zinin](https://apify.com/zinin) (community)
- **Categories:** Social media, Lead generation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.55 / 1,000 profile delivereds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## 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](https://raw.githubusercontent.com/TimmyZinin/apify-actor-assets/e1ce54c6c199c6d374dde18443e244118994a66b/hub10/bluesky-profile-scraper/readme-hero.webp)
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](https://raw.githubusercontent.com/TimmyZinin/apify-actor-assets/e1ce54c6c199c6d374dde18443e244118994a66b/hub10/bluesky-profile-scraper/readme-workflow.webp)

### 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.

```json
{
  "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.)*

```json
{
  "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:

```json
{
  "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:

```json
{
  "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:

```json
{
  "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:

```json
{
  "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:

```json
{
  "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:

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

| `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](https://apify.com/zinin/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](https://apify.com/zinin/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](https://apify.com/zinin/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](https://apify.com/zinin/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](https://apify.com/zinin/telegram-channel-intel) | 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.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.

# Actor input Schema

## `handles` (type: `array`):

Bluesky handles (e.g. bsky.app) or DIDs (e.g. did:plc:...), one per line. A DID is more stable than a handle if you're tracking the same account across weeks — handles can be changed by their owner.

## `maxConcurrency` (type: `integer`):

How many batch requests (up to 25 profiles each) to run in parallel. Kept conservative by default — Bluesky's own robots.txt asks for a handful of concurrent requests at most.

## Actor input object example

```json
{
  "handles": [
    "bsky.app"
  ],
  "maxConcurrency": 5
}
```

# Actor output Schema

## `results` (type: `string`):

API URL for the default dataset items produced by this run.

## `runSummary` (type: `string`):

One JSON object written once at the end of this run: requestedCount, deliveredRows, paidRows, freeRows, failedCount, incompleteRows, replaySafe + replaySafeReason, fatalError + fatalErrorReason, generatedAt.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "handles": [
        "bsky.app"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("zinin/bluesky-profile-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "handles": ["bsky.app"] }

# Run the Actor and wait for it to finish
run = client.actor("zinin/bluesky-profile-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "handles": [
    "bsky.app"
  ]
}' |
apify call zinin/bluesky-profile-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,zinin/bluesky-profile-scraper"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/EjcvnAzfH3dceX9La/builds/lL04NlhDbv2dKzDRx/openapi.json
