Documentation

Pubky SDK

Ergonomic building blocks for Pubky apps: one facade (Pubky) plus focused actors for sessions, storage API, signer helpers, and QR auth flow for keyless apps.

Rust implementation of Pubky SDK.

Install

# Cargo.toml
[dependencies]
pubky = "0.x"            # this crate
# Optional helpers used in examples:
# pubky-testnet = "0.x"

Quick start

use pubky::prelude::*;
use pubky::ClientId;

# async fn run() -> pubky::Result<()> {

let pubky = Pubky::new()?; // or Pubky::testnet() for local testnet.

// 1) Create a new random key user and bound to a Signer
let keypair = Keypair::random();
let signer = pubky.signer(keypair);

// 2) Sign up on a homeserver (identified by its public key)
let homeserver = PublicKey::try_from("o4dksf...uyy").unwrap();
signer.signup(&homeserver, None).await?;
let session = signer.signin(ClientId::new("my-cool-app").unwrap()).await?;

// 3) Read/Write as the signed-in user
session.storage().put("/pub/my-cool-app/hello.txt", "hello").await?;
let body = session.storage().get("/pub/my-cool-app/hello.txt").await?.text().await?;
assert_eq!(&body, "hello");

// 4) Public read of another user’s file
let txt = pubky
    .public_storage()
    .get(format!(
        "{}/pub/my-cool-app/hello.txt",
        session.info().public_key()
    ))
    .await?
    .text().await?;
assert_eq!(txt, "hello");

// 5) Keyless app flow (QR/deeplink)
let caps = Capabilities::builder()
    .write("/pub/example.com/")
    .expect("static scope is canonical")
    .finish();
let flow = pubky.start_grant_auth_flow(
    &caps,
    AuthFlowKind::signin(),
    ClientId::new("my-cool-app").unwrap(),
)?;
println!("Scan to sign in: {}", flow.authorization_url());
let app_session = flow.await_approval().await?;

// 6) Optional (advanced): publish or resolve PKDNS (_pubky) records
signer.pkdns().publish_homeserver_if_stale(None).await?;
let resolved = signer.pkdns().get_homeserver().await;
println!("Your current homeserver: {:?}", resolved);

# Ok(()) }

Error-body limits

HTTP status checking leaves successful response bodies unread. For HTTP errors, the client captures up to 4096 body bytes for the error message. Change this limit when creating the client:

use pubky::{Pubky, PubkyHttpClient};

let client = PubkyHttpClient::builder()
    .max_error_body_bytes(1024)
    .build()?;
let pubky = Pubky::with_client(client);
# Ok::<_, pubky::BuildError>(())

The setting applies to all checked requests made through that client, including credential-refresh errors. Set it to 0 to skip error-body reads and use the HTTP status reason as the message. Positive limits preserve short messages and mark longer ones with \n[response body truncated at N bytes].

The limit caps captured response bytes. Invalid UTF-8 can expand the message to three times that size, plus the truncation marker. Transport buffers and allocations add memory overhead.

In JavaScript, use Pubky.withClient(new Client({ maxErrorBodyBytes: 1024 })).

Event-stream limits

Event subscriptions use sse-core with a default limit of usize::MAX, which is effectively unbounded. Set a client-side limit per subscription to reject oversized or unterminated SSE payloads in historical and live streams:

# async fn example(pubky: pubky::Pubky, user: pubky::PublicKey) -> pubky::Result<()> {
let stream = pubky.event_stream_for_user(&user, None)
    .max_event_bytes(8192)
    .live()
    .subscribe()
    .await?;
# Ok(()) }

In JavaScript, use .maxEventBytes(8192) on the event-stream builder. Rust rejects zero at subscription time; JavaScript requires an integer in 1..=4294967295 when setting the limit.

The limit applies separately to accumulated event data, each event name, and each ID. Data includes the newlines joining data fields; repeated event and id fields replace their previous values. Comments and unknown fields are skipped without buffering their contents. Field prefixes, framing line endings and a leading UTF-8 BOM do not count. There is no total block or stream byte limit. This setting is independent of .limit() (event count) and max_error_body_bytes.

On overflow, the SDK closes the response and yields one validation error, then ends the Rust stream or errors the JavaScript ReadableStream. It still skips malformed Pubky events and unknown event types within the limit.

All subscriptions replace invalid UTF-8 and discard incomplete events at EOF. Transport errors release the response and end the stream. This changes the previous default parser's behavior, which reported UTF-8 errors and could continue after a transport error.

Parser memory is a multiple of the limit because data, event names and IDs have separate buffers, with additional UTF-8 replacement and URL encoding overhead. Transport buffers and events retained by the application are outside this limit.

Key formats (display vs transport)

PublicKey has two string representations:

  • Display format: pubky<z32> (used for logs/UI and human-facing identifiers).
  • Transport/storage format: raw z32 (used for hostnames, storage owner path segments, legacy headers, query params, serde/JSON, and database storage).

Use .z32() whenever you are building hostnames or transport values (for example _pubky.<z32>, /storage/<z32>/..., or the legacy pubky-host header). Use Display/.to_string() when you want the prefixed identifier for people.

Reuse a single facade across your app

Use a shared Pubky (via cloning it, passing down as argument or behind OnceCell) instead of constructing one per request. This avoids reinitializing transports and keeps the same client available for repeated usage.

Mental model

  • Pubky - facade, always start here! Owns the transport and constructs actors.
  • PubkySigner - local key holder. Can signup, signin, approve QR auth, publish PKDNS.
  • PubkySession - authenticated “as me” handle. Exposes session-scoped storage.
  • GrantManager - account-level grant listing and revocation using an authenticated root session.
  • PublicStorage - unauthenticated reads of others’ public data.
  • Pkdns - resolve/publish _pubky records.

Transport:

  • PubkyHttpClient : handles requests to pubky public-key hosts.

Examples

Storage API (session & public)

Session (authenticated):

use pubky::{ClientId, Keypair, Pubky};

# async fn run(keypair: Keypair) -> pubky::Result<()> {

let pubky = Pubky::new()?;
let session = pubky
    .signer(keypair)
    .signin(ClientId::new("my-cool-app").unwrap())
    .await?;

let storage = session.storage();
storage.put("/pub/my-cool-app/data.txt", "hi").await?;
let text = storage.get("/pub/my-cool-app/data.txt").await?.text().await?;

# Ok(()) }

Public (read-only):

use pubky::{Pubky, PublicKey};

# async fn run(user_id: PublicKey) -> pubky::Result<()> {

let pubky = Pubky::new()?;
let public = pubky.public_storage();

let file = public
    .get(format!("{user_id}/pub/example.com/file.bin"))
    .await?
    .bytes()
    .await?;

let entries = public
    .list(format!("{user_id}/pub/example.com/"))?
    .limit(10)
    .send()
    .await?;
for entry in entries {
    println!("{}", entry.to_pubky_url());
}

# Ok(()) }

See the Public Storage example.

Path rules:

  • Session storage uses absolute paths under /pub/ or /priv/.
  • Public storage uses addressed form pubky<user>/pub/app/file.txt (preferred) or pubky://<user>/....
  • A storage path cannot be both an exact file and an implicit folder prefix. For example:
    • if /pub/app/foo exists, writing /pub/app/foo/bar.json returns 409 Conflict.
    • if descendants under /pub/app/foo/ exist, writing /pub/app/foo returns 409 Conflict.

Convention: put your app’s public data under a domain-like folder in /pub, e.g. /pub/my-new-app/.

See Private Storage for /priv/ access and events.

Resolve identifiers into transport URLs

Use [resolve_pubky] to turn a public resource identifier into its canonical HTTPS homeserver URL. PubkyHttpClient::request_async resolves the transport and falls back to legacy storage addressing when needed:

# use pubky::{PubkyHttpClient, resolve_pubky};
# use reqwest::Method;
# async fn run() -> pubky::Result<()> {
let client = PubkyHttpClient::new()?;
let url = resolve_pubky("pubkyoperrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo/pub/pubky.app/posts/0033X02JAN0SG")?;
assert_eq!(
    url.as_str(),
    "https://_pubky.operrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo/storage/operrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo/pub/pubky.app/posts/0033X02JAN0SG"
);

let response = client.request_async(Method::GET, url).await?.send().await?;
# Ok(())
# }

The high-level Rust storage APIs and JavaScript Client.fetch do this automatically, so their storage requests remain compatible with older homeservers. Other HTTP clients can send the canonical /storage/{owner}/... URL only to homeservers that advertise path-addressed-storage.

PKDNS (Pkarr)

Resolve another user’s homeserver (_pubky record), or publish your own via the signer.

use pubky::{Pubky, PublicKey, Keypair};
# async fn run(other: PublicKey, new_homeserver_id: PublicKey) -> pubky::Result<()> {
let pubky = Pubky::new()?;

// read-only homeserver resolver
let host: Option<PublicKey> = pubky.get_homeserver_of(&other).await?;

// publish with your key
let signer = pubky.signer(Keypair::random());
signer.pkdns().publish_homeserver_if_stale(None).await?;
// or force republish (e.g. homeserver migration)
signer.pkdns().publish_homeserver_force(Some(&new_homeserver_id)).await?;
// resolve your own homeserver
signer.pkdns().get_homeserver().await?;

# Ok(()) }

Pubky QR auth for third-party and keyless apps

Request an authorization URL and await approval.

Typical usage:

  1. Start an auth flow with pubky.start_grant_auth_flow(&caps, ..). You can also use PubkyGrantAuthFlow::builder() to set a custom relay. The deprecated cookie flow remains available only for compatibility.
  2. Show authorization_url() (QR/deeplink) to the signing device (e.g., Pubky Ring — iOS / Android).
  3. Await await_approval() to obtain a session-bound PubkySession, or await_credential() for a raw GrantCredential/CookieCredential that you can persist, inspect, or lift into a session later via PubkySession::from_{grant,cookie}_credential.
# use pubky::{AuthFlowKind, Capabilities, ClientId, Keypair, Pubky};
# async fn auth() -> pubky::Result<()> {

let pubky = Pubky::new()?;
// Read/Write capabilities for acme.app route
let caps = Capabilities::builder()
    .read_write("/pub/example.com/")
    .expect("static scope is canonical")
    .finish();

// Start the flow using the default relay (see “Relay & reliability” below)
let flow = pubky.start_grant_auth_flow(
    &caps,
    AuthFlowKind::signin(),
    ClientId::new("example.com").expect("static client id is valid"),
)?;
println!("Scan to sign in: {}", flow.authorization_url());

// On the signing device, approve with: signer.approve_auth(flow.authorization_url()).await?;
# pubky.signer(Keypair::random()).approve_auth(flow.authorization_url()).await?;

let session = flow.await_approval().await?;

# Ok(()) }

Optional x-callback parameters let the authenticator return control to the requesting app after it has handled the deep link. They work with both cookie and grant flows:

# use pubky::{AuthFlowKind, Capabilities, ClientId, PubkyGrantAuthFlow};
# use pubky::deep_links::XCallbackParams;
# fn callback_flow() -> pubky::Result<()> {
let caps = Capabilities::builder()
    .read_write("/pub/example.com/")
    .expect("static scope is canonical")
    .finish();
let callbacks = XCallbackParams {
    x_source: Some("Example App".into()),
    x_success: Some("example://auth/success?nonce=unique".into()),
    x_error: Some("example://auth/error?nonce=unique".into()),
    x_cancel: Some("example://auth/cancel?nonce=unique".into()),
};

let flow = PubkyGrantAuthFlow::builder(
    &caps,
    AuthFlowKind::signin(),
    ClientId::new("example.com").expect("static client id is valid"),
)
.x_callback(callbacks)
.start()?;
# drop(flow);
# Ok(()) }

Approve an auth request

signer.approve_auth(authorization_url).await?;

See the fully functional Auth Flow Example.

Relay & reliability

  • If you don’t specify a relay, the auth flow defaults to a Synonym-hosted relay. If that relay is down, logins won’t complete.
  • For production and larger apps, run your own relay (MIT, Docker): https://httprelay.io. The channel is derived as base64url(hash(secret)); the token is end-to-end encrypted with the secret and cannot be decrypted by the relay.

Custom relay example

# use pubky::{Pubky, PubkyGrantAuthFlow, Capabilities, AuthFlowKind, ClientId};
# async fn custom_relay() -> pubky::Result<()> {
let pubky = Pubky::new()?;
let caps = Capabilities::builder()
    .read("/pub/example.com/")
    .expect("static scope is canonical")
    .finish();
let client_id = ClientId::new("my.app").unwrap();
let auth_flow = PubkyGrantAuthFlow::builder(&caps, AuthFlowKind::signin(), client_id)
    .client(pubky.client().clone())
    .relay(url::Url::parse("http://localhost:8080/inbox/")?) // your relay
    .start()?;
# Ok(()) }

Tip: reuse pubky.client() when customising the relay so the flow shares TLS and pkarr configuration with the rest of your application.

Features

  • json: enable Storage helpers (.get_json() / .put_json()) and serde on certain types.
# Cargo.toml
[dependencies]
pubky = { version = "x.y.z", features = ["json"] }

Testing locally

Spin up an ephemeral testnet (DHT + homeserver + relay) and run your tests fully offline:

# use pubky_testnet::{EphemeralTestnet, pubky::{ClientId, Keypair}};
# async fn test() -> pubky_testnet::pubky::Result<()> {

let testnet = EphemeralTestnet::builder().build().await.unwrap();
let homeserver  = testnet.homeserver_app();
let pubky = testnet.sdk()?;

let signer = pubky.signer(Keypair::random());
signer.signup(&homeserver.public_key().into(), None).await?;
let session = signer
    .signin(ClientId::new("my-cool-app").unwrap())
    .await?;

session.storage().put("/pub/my-cool-app/hello.txt", "hi").await?;
let s = session.storage().get("/pub/my-cool-app/hello.txt").await?.text().await?;
assert_eq!(s, "hi");

# Ok(()) }

Keypair and Session persistence

Encrypted Keypair secrets (.pkarr):

use pubky::Pubky;
# fn run() -> pubky::Result<()> {
let pubky = Pubky::new()?;
let signer = pubky.signer_from_recovery_file("/path/to/alice.pkarr", "passphrase")?;
# Ok(()) }

Grant session secrets:

signin creates a grant-backed session. Export its secret with [GrantSessionView::export_local_secret] and restore it with [Pubky::restore_session]. This example loads the keypair of an account that has already signed up.

use pubky::{ClientId, Pubky};
# async fn run() -> pubky::Result<()> {
let pubky = Pubky::new()?;
let signer = pubky.signer_from_recovery_file("/path/to/alice.pkarr", "passphrase")?;
let session = signer
    .signin(ClientId::new("my-cool-app").unwrap())
    .await?;
let grant = session
    .as_grant()
    .expect("signin creates a grant-backed session");
let secret = grant
    .export_local_secret()
    .await
    .expect("signin creates an exportable local PoP key");

// Store `secret` securely, then load it when the application restarts.
let restored = pubky.restore_session(&secret).await?;

# Ok(()) }

The deprecated .sess helpers are cookie-only; write_secret_file panics on grant sessions.

Security: the exported secret is unencrypted and contains the grant and its private proof-of-possession key. Anyone holding it can act within the granted capabilities until the grant expires or is revoked. Store it securely and never log it.

Example code

Check more examples using the Pubky SDK.

JS bindings

Find a wrapper of this crate using wasm_bindgen in npmjs.com. Or build on pubky-sdk codebase under pubky-sdk/bindings/js.


License: MIT Relay: https://httprelay.io (open source; run your own for production)