openapi: 3.1.0
info:
  title: username.md — Public API
  version: 1.0.0
  summary: One handle, one lookup — profiles, DIDs, verified claims, and agent handshake surfaces.
  description: |
    `username.md` gives every handle a machine-resolvable identity: a public
    profile with content negotiation, a `did:web` document, a bundle of signed
    verifiable credentials ("claims"), an ATProto/Bluesky binding, and
    per-user instructions for AI agents (`llms.txt`).

    **This spec documents the live API** served at `https://username.md`.
    Sample code in curl, Python, JavaScript, and Go lives in the
    [username-md-examples](https://github.com/The-Holding-Company/username-md-examples)
    repository, and a human-friendly guide is at
    [username.md/developers.html](https://username.md/developers.html).

    ## Authentication

    Public reads need no credentials. Owner operations (editing your profile,
    issuing claims, binding ATProto, DNS verification) use a Bearer API token.
    You receive your token when you claim a handle, and can regenerate it in
    the [control panel](https://username.md/panel) (shown once — store it
    safely).

    ## Signed responses (RFC 9421)

    Every API response is signed with the platform Ed25519 key: check the
    `Content-Digest`, `Signature-Input`, and `Signature` headers. The public
    verification key is published as a JWKS at
    `/.well-known/http-message-signatures-directory` (kid `platform-ed25519`).
    This lets you verify a response was produced by username.md without
    trusting the transport. See the
    [verify-signature examples](https://github.com/The-Holding-Company/username-md-examples#verifying-signed-responses).

    ## Vocabulary

    - **Handle** — your name on username.md (`chris` → `username.md/chris`).
    - **DID** — a [Decentralized Identifier](https://www.w3.org/TR/did-core/);
      username.md publishes `did:web` documents, which are plain JSON served
      over HTTPS.
    - **Claim** — a statement about you ("owns this domain", "controls this
      GitHub account") signed as a Verifiable Credential (JWS, `vc+jwt`).
  termsOfService: https://username.md/faq.html
  contact:
    name: The Holding Company
    url: https://holdingco.com
    email: hello@holdingco.com
  license:
    name: Proprietary
    url: https://username.md/faq.html
servers:
  - url: https://username.md
    description: Production
tags:
  - name: profile
    description: Public profile — HTML, Markdown, JSON, JSON-LD, or a signed JWS bundle, selected by `Accept` header or `?accept=` alias.
  - name: preferences
    description: Public theme/accent preferences (feeds the profile renderer and preferences.sh).
  - name: claims
    description: Signed Verifiable Credentials issued under the handle's Ed25519 key.
  - name: verifications
    description: Prove ownership of an external domain via DNS TXT challenge.
  - name: atproto
    description: Bind a Bluesky/ATProto DID to your handle (`@you.username.md`).
  - name: keybase
    description: Import your Keybase identity — profile, verified social proofs, PGP key — then re-verify the proofs under this platform's own signed claims.
  - name: identity
    description: Well-known documents agents use to resolve and verify a handle.
  - name: meta
    description: Health and platform keys.
paths:
  /v1/users/{handle}/profile:
    get:
      tags: [profile]
      summary: Get a handle's public profile (content-negotiated)
      description: |
        Default is HTML. Send `Accept: application/json` for the structured
        profile, `text/markdown` for Markdown, `application/ld+json` for a
        schema.org/Person JSON-LD document, or `application/jose+json` for a
        JWS-signed profile bundle you can verify against the handle's
        published key.

        The `?accept=` query alias (`json`, `jsonld`, `md`, `jws`, `html`)
        overrides the header — handy in a browser. `?theme=` previews any of
        the preset themes on the HTML rendering without changing the owner's
        default.
      operationId: getProfile
      parameters:
        - $ref: "#/components/parameters/Handle"
        - in: query
          name: accept
          description: Representation override; unknown values fall back to header negotiation.
          schema:
            type: string
            enum: [html, md, markdown, json, jsonld, ld, jws]
        - in: query
          name: theme
          description: Visitor-side theme preview (HTML only); unknown values fall back to the owner's theme.
          schema:
            $ref: "#/components/schemas/Theme"
      responses:
        "200":
          description: Profile in the negotiated representation.
          content:
            text/html:
              schema: { type: string }
            application/json:
              schema: { $ref: "#/components/schemas/Profile" }
            application/ld+json:
              schema: { $ref: "#/components/schemas/ProfileJsonLd" }
            text/markdown:
              schema: { type: string }
            application/jose+json:
              schema:
                type: string
                description: Compact JWS whose payload is the JSON profile.
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      tags: [profile]
      summary: Update your profile (owner)
      operationId: patchProfile
      security:
        - BearerToken: []
      parameters:
        - $ref: "#/components/parameters/Handle"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProfilePatch" }
      responses:
        "200":
          description: The stored profile section after the merge.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProfilePatch" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }
  /v1/users/{handle}/preferences:
    get:
      tags: [preferences]
      summary: Get a handle's public presentation preferences
      operationId: getPreferences
      parameters:
        - $ref: "#/components/parameters/Handle"
      responses:
        "200":
          description: Theme, accent, and the list of known themes.
          content:
            application/json:
              schema:
                type: object
                required: [theme, themes]
                properties:
                  theme: { $ref: "#/components/schemas/Theme" }
                  accent:
                    type: [string, "null"]
                    description: Hex accent color, or null for the theme default.
                    examples: ["#7ee787"]
                  themes:
                    type: array
                    items: { $ref: "#/components/schemas/Theme" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [preferences]
      summary: Set your default theme and accent (owner)
      operationId: putPreferences
      security:
        - BearerToken: []
      parameters:
        - $ref: "#/components/parameters/Handle"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [theme]
              properties:
                theme: { $ref: "#/components/schemas/Theme" }
                accent:
                  type: [string, "null"]
                  description: "Hex color only (injection-guarded): `#rgb` to `#rrggbbaa`."
                  pattern: "^#[0-9a-fA-F]{3,8}$"
      responses:
        "200":
          description: Stored preferences.
          content:
            application/json:
              schema:
                type: object
                properties:
                  theme: { $ref: "#/components/schemas/Theme" }
                  accent: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }
  /v1/users/{handle}/claims:
    get:
      tags: [claims]
      summary: Get a handle's signed claim bundle
      description: |
        Returns the handle's DID, public JWK, and every non-revoked claim as
        both a compact JWS (`vc+jwt`) and the decoded Verifiable Credential.
        Verify any JWS against `public_jwk` — no API trust required.
      operationId: listClaims
      parameters:
        - $ref: "#/components/parameters/Handle"
      responses:
        "200":
          description: Claim bundle.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ClaimBundle" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [claims]
      summary: Issue a signed claim (owner)
      operationId: issueClaim
      security:
        - BearerToken: []
      parameters:
        - $ref: "#/components/parameters/Handle"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type, value]
              properties:
                type: { $ref: "#/components/schemas/ClaimType" }
                value:
                  description: Claim subject value (shape depends on type).
                  examples: ["chrisbergeron"]
                expires_at:
                  type: [string, "null"]
                  format: date-time
      responses:
        "201":
          description: Claim signed and stored.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Claim" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }
  /v1/users/{handle}/verifications/dns-txt:
    post:
      tags: [verifications]
      summary: Start a DNS TXT domain-ownership challenge (owner)
      description: |
        Returns a TXT record to publish at `_did-web-verify.<domain>`. Once
        published, call the `check` endpoint; success auto-issues a signed
        `hasDomain` credential.
      operationId: issueDnsChallenge
      security:
        - BearerToken: []
      parameters:
        - $ref: "#/components/parameters/Handle"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain]
              properties:
                domain: { type: string, examples: ["chrisbergeron.com"] }
      responses:
        "201":
          description: Challenge issued.
          content:
            application/json:
              schema:
                type: object
                required: [domain, record_name, record_value]
                properties:
                  domain: { type: string }
                  record_name: { type: string, examples: ["_did-web-verify.chrisbergeron.com"] }
                  record_value: { type: string, description: 32-char hex challenge. }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/users/{handle}/verifications/dns-txt/check:
    post:
      tags: [verifications]
      summary: Check a pending DNS TXT challenge (owner)
      operationId: checkDnsChallenge
      security:
        - BearerToken: []
      parameters:
        - $ref: "#/components/parameters/Handle"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain]
              properties:
                domain: { type: string }
      responses:
        "200":
          description: Domain verified; a signed `hasDomain` credential was issued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, examples: [verified] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: No pending challenge for that domain.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: TXT record not found or mismatched (DNS may still be propagating).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /v1/users/{handle}/atproto:
    get:
      tags: [atproto]
      summary: Get a handle's ATProto binding status
      operationId: getAtprotoStatus
      parameters:
        - $ref: "#/components/parameters/Handle"
      responses:
        "200":
          description: Binding status, DNS record, and the signed `atprotoHandle` credential when bound.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AtprotoStatus" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [atproto]
      summary: Bind an ATProto DID to your handle (owner)
      description: |
        Makes `@<handle>.username.md` a valid Bluesky handle. Pass your
        existing `did` (Bluesky → Settings, or resolve it from your current
        handle), or omit it to mint a `did:web` identity. `publish_dns`
        (default true) publishes the `_atproto` TXT record for you.
      operationId: bindAtproto
      security:
        - BearerToken: []
      parameters:
        - $ref: "#/components/parameters/Handle"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                did:
                  type: [string, "null"]
                  description: "`did:plc:<24 base32>` or hostname-only `did:web`; omit to mint did:web."
                  examples: ["did:plc:ewvi7nxzyoun6zhxrhs64oiz"]
                pds:
                  type: [string, "null"]
                  description: PDS endpoint for the did:web document.
                publish_dns:
                  type: boolean
                  default: true
      responses:
        "200":
          description: Updated binding status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AtprotoStatus" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [atproto]
      summary: Remove your ATProto binding (owner)
      operationId: unbindAtproto
      security:
        - BearerToken: []
      parameters:
        - $ref: "#/components/parameters/Handle"
      responses:
        "200":
          description: Binding removed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AtprotoStatus" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/users/{handle}/import/keybase/preview:
    post:
      tags: [keybase]
      summary: Preview a Keybase import (owner)
      description: |
        Dry run — fetches the Keybase account and returns a per-field plan
        (`set` / `keep-existing` / `unchanged`), the links an import would
        add, and whether a PGP key is available. Writes nothing.
      operationId: previewKeybaseImport
      security:
        - BearerToken: []
      parameters:
        - $ref: "#/components/parameters/Handle"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/KeybaseImportRequest" }
      responses:
        "200":
          description: The mapped Keybase identity and the import plan.
          content:
            application/json:
              schema: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: No such Keybase user. }
        "422": { $ref: "#/components/responses/ValidationError" }
        "502": { description: keybase.io unreachable. }
  /v1/users/{handle}/import/keybase:
    post:
      tags: [keybase]
      summary: Import your Keybase identity (owner)
      description: |
        Additive by default — profile fields already set are kept unless
        `overwrite: true`; links are merged and deduped; only proofs Keybase
        itself reports as OK become profile links. The proof snapshot and
        PGP public key land in the public `keybase` section.
      operationId: applyKeybaseImport
      security:
        - BearerToken: []
      parameters:
        - $ref: "#/components/parameters/Handle"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/KeybaseImportRequest" }
      responses:
        "200":
          description: What was applied, plus the resulting profile content.
          content:
            application/json:
              schema: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: No such Keybase user. }
        "422": { $ref: "#/components/responses/ValidationError" }
        "502": { description: keybase.io unreachable. }
  /v1/users/{handle}/keybase:
    get:
      tags: [keybase]
      summary: Get a handle's Keybase import status
      operationId: getKeybaseStatus
      parameters:
        - $ref: "#/components/parameters/Handle"
      responses:
        "200":
          description: |
            Import status — Keybase username, proof snapshot (with
            `our_verification` re-check results when run), PGP fingerprint,
            and a link to the key at `/keybase/pgp.asc`.
          content:
            application/json:
              schema: { type: object }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/users/{handle}/keybase/verify:
    post:
      tags: [keybase, claims]
      summary: Re-verify imported proofs under this platform's keys (owner)
      description: |
        Re-checks website, dns, github, and hackernews proofs with the
        platform's own checkers, then issues a signed `keybaseProof`
        Verifiable Credential per verified proof (replacing prior ones on
        re-run). twitter/facebook/reddit require authenticated APIs and are
        reported `unsupported`. DNS-TXT fallback evidence is explicitly
        marked domain-level.
      operationId: verifyKeybaseProofs
      security:
        - BearerToken: []
      parameters:
        - $ref: "#/components/parameters/Handle"
      responses:
        "200":
          description: Per-proof results and a verified/failed/unsupported summary.
          content:
            application/json:
              schema: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: No Keybase import for this handle yet. }
  /v1/users/{handle}/keybase/pgp.asc:
    get:
      tags: [keybase]
      summary: Imported PGP public key
      operationId: getKeybasePgp
      parameters:
        - $ref: "#/components/parameters/Handle"
      responses:
        "200":
          description: The armored PGP public key imported from Keybase.
          content:
            application/pgp-keys:
              schema: { type: string }
        "404": { $ref: "#/components/responses/NotFound" }
  /users/{handle}/.well-known/atproto-did:
    get:
      tags: [atproto, identity]
      summary: Resolve a handle's ATProto DID (text/plain)
      description: |
        The ATProto handle-resolution surface. Also served host-aware at
        `https://<handle>.username.md/.well-known/atproto-did`, which is what
        Bluesky itself queries.
      operationId: getAtprotoDid
      parameters:
        - $ref: "#/components/parameters/Handle"
      responses:
        "200":
          description: The bound DID, as plain text.
          content:
            text/plain:
              schema: { type: string, examples: ["did:plc:ewvi7nxzyoun6zhxrhs64oiz"] }
        "404": { $ref: "#/components/responses/NotFound" }
  /users/{handle}/.well-known/did.json:
    get:
      tags: [identity]
      summary: Get a handle's did:web document
      operationId: getDidDocument
      parameters:
        - $ref: "#/components/parameters/Handle"
      responses:
        "200":
          description: DID document (DID Core data model).
          content:
            application/did+json:
              schema: { $ref: "#/components/schemas/DidDocument" }
        "404": { $ref: "#/components/responses/NotFound" }
  /.well-known/did.json:
    get:
      tags: [identity]
      summary: Apex did:web document (host-aware)
      description: |
        Resolves by the request `Host` — used when a customer points their own
        domain (external DID, e.g. `did:web:chrisbergeron.com`) at the
        platform.
      operationId: getApexDidDocument
      responses:
        "200":
          description: DID document.
          content:
            application/did+json:
              schema: { $ref: "#/components/schemas/DidDocument" }
        "404": { $ref: "#/components/responses/NotFound" }
  /users/{handle}/llms.txt:
    get:
      tags: [identity]
      summary: Per-handle instructions for AI agents
      description: How an AI agent should interact with this handle — capabilities, contact rules, verification pointers.
      operationId: getLlmsTxt
      parameters:
        - $ref: "#/components/parameters/Handle"
      responses:
        "200":
          description: Agent instructions.
          content:
            text/plain:
              schema: { type: string }
        "404": { $ref: "#/components/responses/NotFound" }
  /.well-known/http-message-signatures-directory:
    get:
      tags: [meta, identity]
      summary: Platform response-signing keys (JWKS)
      description: |
        JWKS holding the Ed25519 key (kid `platform-ed25519`) that signs every
        API response per RFC 9421. Use it to verify `Content-Digest` /
        `Signature` headers.
      operationId: getSignatureDirectory
      responses:
        "200":
          description: JWKS.
          content:
            application/json:
              schema:
                type: object
                required: [keys]
                properties:
                  keys:
                    type: array
                    items:
                      type: object
                      properties:
                        kty: { type: string, examples: [OKP] }
                        crv: { type: string, examples: [Ed25519] }
                        kid: { type: string, examples: [platform-ed25519] }
                        x: { type: string }
  /healthz:
    get:
      tags: [meta]
      summary: Liveness probe
      operationId: healthz
      responses:
        "200":
          description: Service is alive.
components:
  parameters:
    Handle:
      in: path
      name: handle
      required: true
      description: A username.md handle — lowercase letters, digits, `_`/`-` inside, 2–63 chars.
      schema:
        type: string
        pattern: "^[a-z0-9]([a-z0-9_-]{0,61}[a-z0-9])?$"
        examples: [chris]
  responses:
    NotFound:
      description: Handle or resource not found.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing or invalid Bearer token.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ValidationError:
      description: Request failed validation.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
  securitySchemes:
    BearerToken:
      type: http
      scheme: bearer
      description: Owner API token — issued at claim time, regenerable in the control panel (`/panel`).
  schemas:
    KeybaseImportRequest:
      type: object
      required: [keybase_username]
      properties:
        keybase_username:
          type: string
          pattern: "^[a-zA-Z0-9_]{2,16}$"
          examples: [chris]
        overwrite:
          type: boolean
          default: false
          description: Replace profile fields that are already set. Links are always merged, never removed.
    Theme:
      type: string
      description: Preset profile theme.
      enum: [professional, playful, business-card, personal, discreet, terminal, high-contrast]
    Profile:
      type: object
      required: [handle, did, public_jwk]
      properties:
        handle: { type: string, examples: [chris] }
        did: { type: string, examples: ["did:web:chrisbergeron.com"] }
        external_did:
          type: [string, "null"]
          description: Customer-owned DID apex when the handle is bound to their own domain.
        public_jwk:
          $ref: "#/components/schemas/Jwk"
        name: { type: string }
        tagline: { type: string }
        links:
          type: array
          items:
            type: object
            required: [href]
            properties:
              label: { type: string }
              href: { type: string, examples: ["https://github.com/chrisbergeron"] }
    ProfileJsonLd:
      type: object
      description: schema.org/Person rendering of the profile.
      properties:
        "@context": { type: string, examples: ["https://schema.org"] }
        "@type": { type: string, examples: [Person] }
        identifier: { type: string }
        name: { type: string }
        url: { type: string }
    ProfilePatch:
      type: object
      description: Partial update — omitted fields are left unchanged.
      properties:
        name: { type: string, maxLength: 120 }
        tagline: { type: string, maxLength: 280 }
        links:
          type: array
          maxItems: 50
          items:
            type: object
            required: [href]
            properties:
              label: { type: string }
              href:
                type: string
                description: Must start with `https://`, `http://`, or `mailto:`.
    ClaimType:
      type: string
      enum:
        - alsoKnownAs
        - hasGitHub
        - hasEmail
        - hasDomain
        - hasResume
        - hasPublicKey
        - hasContact
        - atprotoHandle
        - introductionRequest
    Claim:
      type: object
      required: [id, type, jws, credential]
      properties:
        id: { type: string, format: uuid }
        type: { $ref: "#/components/schemas/ClaimType" }
        jws:
          type: string
          description: Compact JWS (`typ":"vc+jwt`) signed with the handle's Ed25519 key.
        credential:
          type: object
          description: The decoded W3C Verifiable Credential.
    ClaimBundle:
      type: object
      required: [did, public_jwk, claims]
      properties:
        did: { type: string }
        public_jwk: { $ref: "#/components/schemas/Jwk" }
        claims:
          type: array
          items: { $ref: "#/components/schemas/Claim" }
    AtprotoStatus:
      type: object
      required: [handle, atproto_handle, bound]
      properties:
        handle: { type: string, examples: [chris] }
        atproto_handle: { type: string, examples: [chris.username.md] }
        did: { type: [string, "null"] }
        bound: { type: boolean }
        dns_record:
          type: [object, "null"]
          description: The `_atproto` TXT record content when bound.
        did_web:
          type: object
          properties:
            did: { type: string }
            enabled: { type: boolean }
            multikey: { type: [string, "null"] }
            pds: { type: [string, "null"] }
            did_json: { type: string }
        dns_publish: { type: [object, "null"] }
        bound_at: { type: [string, "null"], format: date-time }
        claim:
          type: object
          description: The signed `atprotoHandle` credential (id, jws, credential).
    DidDocument:
      type: object
      description: Minimal did:web document (DID Core data model).
      required: [id]
      properties:
        "@context":
          oneOf:
            - type: string
            - type: array
              items: { type: string }
        id: { type: string, examples: ["did:web:username.md:users:chris"] }
        alsoKnownAs:
          type: array
          items: { type: string }
        verificationMethod:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              type: { type: string }
              controller: { type: string }
              publicKeyJwk: { $ref: "#/components/schemas/Jwk" }
        authentication:
          type: array
          items: { type: string }
        assertionMethod:
          type: array
          items: { type: string }
    Jwk:
      type: object
      description: Ed25519 public key (JWK).
      properties:
        kty: { type: string, examples: [OKP] }
        crv: { type: string, examples: [Ed25519] }
        x: { type: string }
        alg: { type: string, examples: [EdDSA] }
        use: { type: string, examples: [sig] }
    Error:
      type: object
      required: [detail]
      properties:
        detail:
          type: string
          description: Human-readable error message.
externalDocs:
  description: Developer guide + runnable examples
  url: https://username.md/developers.html
