Header menu logo FSharp.ATProto

Identity Resolution

All examples use taskResult {}. See the Error Handling guide for details.

The AT Protocol uses two kinds of identifiers for accounts:

Handles can change; DIDs cannot. The Identity module provides functions to resolve between them, with optional bidirectional verification to ensure they agree.

Resolving an Identity (Recommended)

resolveIdentity is the recommended entry point. It accepts a plain string -- either a handle or a DID -- performs forward and reverse resolution, and checks that both sides agree.

open FSharp.ATProto.Core
open FSharp.ATProto.Bluesky
open FSharp.ATProto.Syntax

taskResult {
    let! identity = Identity.resolveIdentity agent "my-handle.bsky.social"
    printfn "Verified DID: %s" (Did.value identity.Did)

    match identity.Handle with
    | Some h -> printfn "Verified handle: %s" (Handle.value h)
    | None -> printfn "Handle could not be verified"

    return identity
}

Unlike resolveHandle and resolveDid which take typed Handle and Did respectively, resolveIdentity accepts a plain string -- it figures out whether you gave it a handle or a DID and does the right thing.

The verification works as follows:

  1. Input is a handle: resolve handle to DID, then fetch the DID document and check that it claims the same handle in its alsoKnownAs field.
  2. Input is a DID: fetch the DID document to get the claimed handle, then resolve that handle back to a DID and check they match.

If the bidirectional check fails, the returned AtprotoIdentity will have Handle = None rather than an unverified value.

Typed Alternatives

If you already have a typed Handle or Did, you can use the single-direction functions directly. These perform one lookup without bidirectional verification.

Resolve a Handle to a DID:

// Handle.parse returns Result<Handle, string> -- handle the error first
match Handle.parse "my-handle.bsky.social" with
| Ok handle ->
    taskResult {
        let! did = Identity.resolveHandle agent handle
        printfn "DID: %s" (Did.value did)
    }
    |> ignore
| Error msg -> printfn "Invalid handle: %s" msg

Resolve a DID to full identity info:

match Did.parse "did:plc:z72i7hdynmk6r22z27h6tvur" with
| Ok did ->
    taskResult {
        let! identity = Identity.resolveDid agent did
        printfn "Handle: %s" (identity.Handle |> Option.map Handle.value |> Option.defaultValue "(none)")
        printfn "PDS: %s" (identity.PdsEndpoint |> Option.map string |> Option.defaultValue "(none)")
    }
    |> ignore
| Error msg -> printfn "Invalid DID: %s" msg

resolveDid fetches the DID document and extracts the handle from alsoKnownAs, the PDS endpoint from the service entries, and the signing key from the verificationMethod entries. Both did:plc: (via the PLC directory) and did:web: (via .well-known/did.json) methods are supported.

The AtprotoIdentity Type

All fields use the library's typed identifiers, not raw strings:

type AtprotoIdentity =
    { Did: Did                  // Typed DID -- use Did.value to get the string
      Handle: Handle option     // Typed Handle -- present only if verified
      PdsEndpoint: Uri option   // FSharp.ATProto.Syntax.Uri
      SigningKey: string option }

To print or display these values, use the accessor functions:

let showIdentity (id: Identity.AtprotoIdentity) =
    printfn "DID: %s" (Did.value id.Did)
    printfn "Handle: %s" (id.Handle |> Option.map Handle.value |> Option.defaultValue "(unverified)")
    printfn "PDS: %s" (id.PdsEndpoint |> Option.map string |> Option.defaultValue "(unknown)")
    printfn "Key: %s" (id.SigningKey |> Option.defaultValue "(none)")

Error Handling

Identity resolution functions return errors via the IdentityError discriminated union:

type IdentityError =
    | XrpcError of XrpcError         // An XRPC call failed (network, auth, etc.)
    | DocumentParseError of string   // The DID document couldn't be fetched or parsed

You can pattern match on this to handle each case differently:

match result with
| Ok identity ->
    printfn "Resolved: %s" (Did.value identity.Did)
| Error (IdentityError.XrpcError xrpcErr) ->
    printfn "Network or API error: %A" xrpcErr
| Error (IdentityError.DocumentParseError msg) ->
    printfn "Malformed DID document: %s" msg

XrpcError covers failures during XRPC calls -- authentication issues, network problems, rate limiting. DocumentParseError covers cases where a DID document was retrieved but couldn't be parsed into a valid identity (missing required fields, malformed JSON, etc.).

Parsing DID Documents Directly

If you already have a DID document as a JsonElement, you can parse it without making any network calls:

let! response = agent.HttpClient.GetAsync("https://plc.directory/did:plc:z72i7hdynmk6r22z27h6tvur")
let! json = response.Content.ReadAsStringAsync()
let doc = System.Text.Json.JsonSerializer.Deserialize<System.Text.Json.JsonElement>(json)

match Identity.parseDidDocument doc with
| Ok identity -> printfn "Parsed: %s" (Did.value identity.Did)
| Error msg -> printfn "Invalid document: %s" msg

Note that parseDidDocument returns Result<AtprotoIdentity, string> (not IdentityError), since there's no network call involved -- the only failure mode is a malformed document.

When to Use Which Function

Function

Input

Returns

Network Calls

Verification

resolveIdentity

string

Result<AtprotoIdentity, IdentityError>

2 (forward + reverse)

Bidirectional

resolveHandle

Handle

Result<Did, IdentityError>

1 (XRPC)

None

resolveDid

Did

Result<AtprotoIdentity, IdentityError>

1 (HTTP)

None

parseDidDocument

JsonElement

Result<AtprotoIdentity, string>

0

None

Use resolveIdentity when you need confidence that a handle and DID actually belong together -- it's the safest choice for displaying user identity in your application. Use resolveHandle or resolveDid when you only need a quick lookup and trust the input. Use parseDidDocument when you already have the raw DID document in hand.

Multiple items
namespace FSharp

--------------------
namespace Microsoft.FSharp
namespace FSharp.ATProto
namespace FSharp.ATProto.Syntax
namespace FSharp.ATProto.Core
namespace FSharp.ATProto.Bluesky
val agent: AtpAgent
module Unchecked from Microsoft.FSharp.Core.Operators
val defaultof<'T> : 'T
Multiple items
module AtpAgent from FSharp.ATProto.Core
<summary> Functions for creating and authenticating <see cref="AtpAgent" /> instances. </summary>

--------------------
type AtpAgent = { HttpClient: HttpClient mutable BaseUrl: Uri mutable Session: AtpSession option ExtraHeaders: (string * string) list AuthenticateRequest: (HttpRequestMessage -> unit) option RefreshAuthentication: (unit -> Task<Result<unit,XrpcError>>) option OnSessionChanged: (unit -> unit) option }
<summary> Client agent for communicating with an AT Protocol Personal Data Server (PDS). Holds the HTTP client, base URL, optional authenticated session, and extra headers. </summary>
<remarks> Create an agent with <see cref="AtpAgent.create" /> or <see cref="AtpAgent.createWithClient" />, then authenticate with <see cref="AtpAgent.login" />. The agent's <see cref="Session" /> field is mutable: it is updated automatically on login and token refresh. </remarks>
<example><code> let agent = AtpAgent.create "https://bsky.social" let! session = AtpAgent.login "my-handle.bsky.social" "app-password-here" agent </code></example>
val handle: Handle
Multiple items
module Handle from FSharp.ATProto.Syntax
<summary> Functions for creating, validating, and extracting data from <see cref="Handle" /> values. </summary>

--------------------
type Handle = private | Handle of string override ToString: unit -> string
<summary> A handle (domain name) used as a human-readable identifier in the AT Protocol. Handles are DNS-based names (e.g. <c>my-handle.bsky.social</c>) that resolve to a <see cref="Did" />. They must be valid domain names with at least two segments and a maximum length of 253 characters. </summary>
<remarks> See the AT Protocol specification: https://atproto.com/specs/handle </remarks>
val did: Did
Multiple items
module Did from FSharp.ATProto.Syntax
<summary> Functions for creating, validating, and extracting data from <see cref="Did" /> values. </summary>

--------------------
type Did = private | Did of string override ToString: unit -> string
<summary> A decentralized identifier (DID) as defined by the AT Protocol. DIDs are the primary stable identifier for accounts. Two methods are currently supported: <c>did:plc:</c> (hosted, managed by PLC directory) and <c>did:web:</c> (self-hosted, DNS-based). </summary>
<remarks> See the AT Protocol specification: https://atproto.com/specs/did and the W3C DID specification: https://www.w3.org/TR/did-core/ </remarks>
val taskResult: TaskResultBuilder
val identity: Identity.AtprotoIdentity
module Identity from FSharp.ATProto.Bluesky
<summary> AT Protocol identity resolution: DID documents, handle resolution, and bidirectional verification. Supports both <c>did:plc</c> (via PLC directory) and <c>did:web</c> (via .well-known) methods. </summary>
val resolveIdentity: agent: AtpAgent -> identifier: string -> System.Threading.Tasks.Task<Result<Identity.AtprotoIdentity,IdentityError>>
<summary> Fully resolve an AT Protocol identity with bidirectional verification. Accepts either a DID or a handle and performs the forward + reverse resolution needed to confirm the handle-DID binding. </summary>
<param name="agent">An authenticated <see cref="AtpAgent" />.</param>
<param name="identifier"> A DID (starting with <c>did:</c>) or a handle (e.g., <c>my-handle.bsky.social</c>). </param>
<returns><c>Ok</c> with the resolved <see cref="AtprotoIdentity" />. If bidirectional verification fails (the reverse lookup does not match), the <c>Handle</c> field is set to <c>None</c> but the identity is still returned. Returns <c>Error</c> on resolution failure. </returns>
<remarks> Bidirectional verification ensures that both directions of the DID-handle binding agree: the DID document must list the handle in <c>alsoKnownAs</c>, and resolving that handle must return the same DID. If either direction fails, the handle is cleared but the identity (DID, PDS endpoint, signing key) is still returned. </remarks>
<example><code> let! identity = Identity.resolveIdentity agent "my-handle.bsky.social" match identity with | Ok id -&gt; printfn "DID: %s, Handle verified: %b" (Did.value id.Did) id.Handle.IsSome | Error msg -&gt; printfn "Resolution failed: %A" msg </code></example>
val printfn: format: Printf.TextWriterFormat<'T> -> 'T
val value: Did -> string
<summary> Extract the string representation of a DID. </summary>
<param name="did">The DID to extract the value from.</param>
<returns>The full DID string (e.g. <c>"did:plc:z72i7hdynmk6r22z27h6tvur"</c>).</returns>
Identity.AtprotoIdentity.Did: Did
<summary>The decentralized identifier (e.g., <c>did:plc:z72i7hdynmk6r22z27h6tvur</c>).</summary>
Identity.AtprotoIdentity.Handle: Handle option
<summary>The handle claimed in the DID document's <c>alsoKnownAs</c> field, if present and verified.</summary>
union case Option.Some: Value: 'T -> Option<'T>
val h: Handle
val value: Handle -> string
<summary> Extract the string representation of a handle. </summary>
<param name="handle">The handle to extract the value from.</param>
<returns>The full handle string (e.g. <c>"my-handle.bsky.social"</c>).</returns>
union case Option.None: Option<'T>
val parse: s: string -> Result<Handle,string>
<summary> Parse and validate a handle string. </summary>
<param name="s"> A handle string in domain-name format (e.g. <c>"my-handle.bsky.social"</c>). Must be a valid hostname with at least two segments, each segment starting and ending with an alphanumeric character, and the TLD starting with a letter. </param>
<returns><c>Ok</c> with a validated <see cref="Handle" />, or <c>Error</c> with a message describing the validation failure. Validation failures include: null input, exceeding the 253-character limit, or invalid hostname syntax. </returns>
<example><code> match Handle.parse "my-handle.bsky.social" with | Ok handle -&gt; printfn "Valid: %s" (Handle.value handle) | Error e -&gt; printfn "Invalid: %s" e </code></example>
union case Result.Ok: ResultValue: 'T -> Result<'T,'TError>
val resolveHandle: agent: AtpAgent -> handle: Handle -> System.Threading.Tasks.Task<Result<Did,IdentityError>>
<summary> Resolve a handle to its DID via <c>com.atproto.identity.resolveHandle</c>. </summary>
<param name="agent">An authenticated <see cref="AtpAgent" />.</param>
<param name="handle">The handle to resolve (e.g., <c>my-handle.bsky.social</c>).</param>
<returns><c>Ok</c> with the resolved <see cref="Did" /> on success, or <c>Error</c> with an <see cref="IdentityError" /> if the handle cannot be resolved. </returns>
val ignore: value: 'T -> unit
union case Result.Error: ErrorValue: 'TError -> Result<'T,'TError>
val msg: string
val parse: s: string -> Result<Did,string>
<summary> Parse and validate a DID string. </summary>
<param name="s"> A DID string in the format <c>did:&lt;method&gt;:&lt;method-specific-id&gt;</c> (e.g. <c>"did:plc:z72i7hdynmk6r22z27h6tvur"</c> or <c>"did:web:example.com"</c>). </param>
<returns><c>Ok</c> with a validated <see cref="Did" />, or <c>Error</c> with a message describing the validation failure. Validation failures include: null input, exceeding the 2048-character limit, invalid DID syntax, or invalid percent-encoding. </returns>
<example><code> match Did.parse "did:plc:z72i7hdynmk6r22z27h6tvur" with | Ok did -&gt; printfn "Valid: %s" (Did.value did) | Error e -&gt; printfn "Invalid: %s" e </code></example>
val resolveDid: agent: AtpAgent -> did: Did -> System.Threading.Tasks.Task<Result<Identity.AtprotoIdentity,IdentityError>>
<summary> Resolve a DID to an <see cref="AtprotoIdentity" /> by fetching its DID document. </summary>
<param name="agent">An <see cref="AtpAgent" /> whose <c>HttpClient</c> is used for the HTTP request.</param>
<param name="did"> The DID to resolve. Must start with <c>did:plc:</c> (resolved via PLC directory) or <c>did:web:</c> (resolved via <c>.well-known/did.json</c>). </param>
<returns><c>Ok</c> with the parsed identity on success, or <c>Error</c> with an <see cref="IdentityError" /> on HTTP failure or unsupported DID method. </returns>
module Option from Microsoft.FSharp.Core
val map: mapping: ('T -> 'U) -> option: 'T option -> 'U option
val defaultValue: value: 'T -> option: 'T option -> 'T
Identity.AtprotoIdentity.PdsEndpoint: Uri option
<summary>The PDS (Personal Data Server) endpoint URL from the DID document's service entries.</summary>
Multiple items
val string: value: 'T -> string

--------------------
type string = System.String
type AtprotoIdentity = { Did: obj Handle: obj PdsEndpoint: obj SigningKey: string option }
type 'T option = Option<'T>
val showIdentity: id: Identity.AtprotoIdentity -> unit
val id: Identity.AtprotoIdentity
type AtprotoIdentity = { Did: Did Handle: Handle option PdsEndpoint: Uri option SigningKey: string option }
<summary> A resolved AT Protocol identity containing the DID and optional metadata extracted from the DID document. </summary>
Identity.AtprotoIdentity.SigningKey: string option
<summary>The atproto signing key in multibase encoding from the DID document's verification methods.</summary>
type IdentityError = | XrpcError of obj | DocumentParseError of string
val result: Result<Identity.AtprotoIdentity,IdentityError>
Multiple items
module Result from Microsoft.FSharp.Core

--------------------
[<Struct>] type Result<'T,'TError> = | Ok of ResultValue: 'T | Error of ErrorValue: 'TError
type IdentityError = | XrpcError of XrpcError | DocumentParseError of string
<summary> Errors that can occur during identity resolution. </summary>
union case IdentityError.XrpcError: XrpcError -> IdentityError
<summary>An XRPC call failed (e.g., handle resolution).</summary>
val xrpcErr: XrpcError
union case IdentityError.DocumentParseError: string -> IdentityError
<summary>A DID document could not be fetched or parsed.</summary>
namespace System
namespace System.Text
namespace System.Text.Json
System.Text.Json.JsonSerializer.Deserialize<'TValue>(reader: byref<System.Text.Json.Utf8JsonReader>, jsonTypeInfo: System.Text.Json.Serialization.Metadata.JsonTypeInfo<'TValue>) : 'TValue
   (+0 other overloads)
[<RequiresDynamicCodeAttribute ("JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation. Use System.Text.Json source generation for native AOT applications."); RequiresUnreferencedCodeAttribute ("JSON serialization and deserialization might require types that cannot be statically analyzed. Use the overload that takes a JsonTypeInfo or JsonSerializerContext, or make sure all of the required types are preserved.")>] System.Text.Json.JsonSerializer.Deserialize<'TValue>(reader: byref<System.Text.Json.Utf8JsonReader>, ?options: System.Text.Json.JsonSerializerOptions) : 'TValue
   (+0 other overloads)
System.Text.Json.JsonSerializer.Deserialize<'TValue>(node: System.Text.Json.Nodes.JsonNode, jsonTypeInfo: System.Text.Json.Serialization.Metadata.JsonTypeInfo<'TValue>) : 'TValue
   (+0 other overloads)
[<RequiresDynamicCodeAttribute ("JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation. Use System.Text.Json source generation for native AOT applications."); RequiresUnreferencedCodeAttribute ("JSON serialization and deserialization might require types that cannot be statically analyzed. Use the overload that takes a JsonTypeInfo or JsonSerializerContext, or make sure all of the required types are preserved.")>] System.Text.Json.JsonSerializer.Deserialize<'TValue>(node: System.Text.Json.Nodes.JsonNode, ?options: System.Text.Json.JsonSerializerOptions) : 'TValue
   (+0 other overloads)
System.Text.Json.JsonSerializer.Deserialize<'TValue>(element: System.Text.Json.JsonElement, jsonTypeInfo: System.Text.Json.Serialization.Metadata.JsonTypeInfo<'TValue>) : 'TValue
   (+0 other overloads)
[<RequiresDynamicCodeAttribute ("JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation. Use System.Text.Json source generation for native AOT applications."); RequiresUnreferencedCodeAttribute ("JSON serialization and deserialization might require types that cannot be statically analyzed. Use the overload that takes a JsonTypeInfo or JsonSerializerContext, or make sure all of the required types are preserved.")>] System.Text.Json.JsonSerializer.Deserialize<'TValue>(element: System.Text.Json.JsonElement, ?options: System.Text.Json.JsonSerializerOptions) : 'TValue
   (+0 other overloads)
System.Text.Json.JsonSerializer.Deserialize<'TValue>(document: System.Text.Json.JsonDocument, jsonTypeInfo: System.Text.Json.Serialization.Metadata.JsonTypeInfo<'TValue>) : 'TValue
   (+0 other overloads)
[<RequiresDynamicCodeAttribute ("JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation. Use System.Text.Json source generation for native AOT applications."); RequiresUnreferencedCodeAttribute ("JSON serialization and deserialization might require types that cannot be statically analyzed. Use the overload that takes a JsonTypeInfo or JsonSerializerContext, or make sure all of the required types are preserved.")>] System.Text.Json.JsonSerializer.Deserialize<'TValue>(document: System.Text.Json.JsonDocument, ?options: System.Text.Json.JsonSerializerOptions) : 'TValue
   (+0 other overloads)
System.Text.Json.JsonSerializer.Deserialize<'TValue>([<StringSyntaxAttribute ("Json")>] json: string, jsonTypeInfo: System.Text.Json.Serialization.Metadata.JsonTypeInfo<'TValue>) : 'TValue
   (+0 other overloads)
[<RequiresDynamicCodeAttribute ("JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation. Use System.Text.Json source generation for native AOT applications."); RequiresUnreferencedCodeAttribute ("JSON serialization and deserialization might require types that cannot be statically analyzed. Use the overload that takes a JsonTypeInfo or JsonSerializerContext, or make sure all of the required types are preserved.")>] System.Text.Json.JsonSerializer.Deserialize<'TValue>([<StringSyntaxAttribute ("Json")>] json: string, ?options: System.Text.Json.JsonSerializerOptions) : 'TValue
   (+0 other overloads)
[<Struct>] type JsonElement = member Clone: unit -> JsonElement member EnumerateArray: unit -> ArrayEnumerator member EnumerateObject: unit -> ObjectEnumerator member GetArrayLength: unit -> int member GetBoolean: unit -> bool member GetByte: unit -> byte member GetBytesFromBase64: unit -> byte array member GetDateTime: unit -> DateTime member GetDateTimeOffset: unit -> DateTimeOffset member GetDecimal: unit -> decimal ...
<summary>Represents a specific JSON value within a <see cref="T:System.Text.Json.JsonDocument" />.</summary>

Type something to start searching.