Request proxying
The docs on this site mostly cover authenticated API requests. Authenticated API clients actually behave differently behind the scenes than unauthenticated clients: they route their requests across different endpoints of the Bluesky API. These endpoints don't all live on the same host, and where a request should be sent depends on whether the caller is authenticated:
- Most
app.bsky.*GETs are public and can be called without authentication against the Bluesky API athttps://public.api.bsky.app. You can authenticate calls to the Bluesky API in order toPOSTor receive account-specific data, such as notifications, following, mute, and block status. - Authenticated requests should be sent to the user's own PDS. The PDS validates the session and, if needed, proxies the request to the correct backend. Proxied requests should include an
atproto-proxyheader. Bluesky DMs and Ozone Moderation requests always require proxying.
The TypeScript SDK handles this routing for you. The rest of this page explains what it does by default, and how to take explicit control of it.
What the TypeScript SDK does by default
The client sends every authenticated request to your PDS and splits the routing:
- Direct Lexicon
calls are proxied through the PDS to the Bluesky app. When you pass a LexiconGETorPOSTtoclient.call, your PDS handles authentication and routes the call to the Bluesky API. - All other "sugar" methods call your PDS directly without proxying. The record helpers —
client.create,client.get,client.put,client.delete,client.list— and the actions built on them (likepostandfollow) operate on your own repo, which lives on your PDS.
import { app, com } from '@bsky/sdk/lexicons'
import { currentDatetimeString } from '@atproto/lex'
// A session can be created from @atproto/lex-password-auth or @atproto/oauth-client-browser
const client = new Client(session, { service: api.app.service })
// Creating a block record with a direct lexicon call: proxied
await client.call(com.atproto.repo.createRecord, {
repo: client.assertDid,
collection: 'app.bsky.graph.block',
record: {
$type: 'app.bsky.graph.block',
subject: did,
createdAt: currentDatetimeString(),
},
})
// The same operation as a sugar method: not proxied, writes to your repo, on your PDS
const { uri } = await client.create(app.bsky.graph.block, {
subject: did,
createdAt: currentDatetimeString(),
})
These two calls are 1:1. Both create the same record through
com.atproto.repo.createRecord, but the sugar method fills in the
boilerplate and always addresses your PDS, while the direct call is routed
like any other Lexicon request.
The same rule extends to preferences: the getPreferences and
updatePreferences actions target your PDS by default, because
preferences live on your PDS rather than in the App.
Taking stricter control
The default behavior above is consistent, but it's implicit; nothing in the code says which host serves each request. The SDK also provides three levels of explicitness.
These can be particularly helpful if you're implementing your own PDS or your own App in a way that's inconsistent with the caching assumptions made by Bluesky.
Pin the client to a service
The api constants address Bluesky's services. Constructing the client with a
service makes every direct lexicon call carry an explicit
atproto-proxy header
naming its destination (sugar methods still target your PDS):
import { Client } from '@atproto/lex'
import { api } from '@bsky/sdk'
// api.app.service === 'did:web:api.bsky.app#bsky_appview'
const bskyClient = new Client(session, { service: api.app.service })
Override the service per request
Every client.call and client.xrpc accepts a service option. Pass null
to make a read or write against your PDS itself rather than the Bluesky API:
import { com } from '@bsky/sdk/lexicons'
const res = await bskyClient.call(
com.atproto.repo.listRecords,
{ repo: bskyClient.assertDid, collection: 'app.bsky.feed.post' },
{ service: null }, // target the PDS rather than the Bluesky API
)
Or pass a different service identifier to route a call elsewhere. For example, sending a moderation report to an independent labeler:
await bskyClient.call(com.atproto.moderation.createReport, report, {
service: 'did:web:my-labeler.example.com#atproto_labeler',
})
Keep two clients
If you'd rather never mix the two kinds of traffic on one client, construct a separate client for each from the same session — one that only ever talks to your PDS, and one for the Bluesky API:
// Repo reads and writes: never proxied
const accountClient = new Client(session)
// Bluesky API queries: explicitly proxied
const bskyClient = new Client(session, { service: api.app.service })
You can use the same principle for calling the API with curl over raw HTTP: send
authenticated requests to your PDS and set the
atproto-proxy header
yourself when a request needs to reach a specific service.