openapi: 3.0.3

info:
  title: When Did They Join Bluesky? — Public API
  version: "1.0.0"
  description: |
    A small read-only API over Bluesky account join dates.

    The core question it answers is deliberately narrow: **how many whole days
    ago did this account join Bluesky?** That is enough for an onboarding bot to
    decide whether a handle is new enough to greet, for an adoption dashboard to
    count new arrivals, or for a moderation filter to treat brand-new accounts
    with more suspicion.

    ### Data source and honesty about it

    Join dates come from the account's PLC directory audit log where one exists
    (`join_date_source: plc`). If it does not, the API falls back to the
    `createdAt` on the profile record (`join_date_source: profile_record`), which
    is self-asserted and less trustworthy. `did:web` accounts have no PLC audit
    log at all; rather than guessing from `indexedAt` — which is when Bluesky's
    AppView first saw the account, not when it was created — those return
    **422 `join_date_unavailable`**. Callers that need a hard guarantee should
    check `join_date_source` rather than trusting every 200 equally.

    ### Caching

    Successful responses carry `Cache-Control: public, max-age=900`. Errors carry
    `no-store`, so a handle that 404s today is not remembered as missing
    tomorrow. Profiles are also memoized server-side for six hours, but
    `days_since_joined` is recomputed on every request so it never goes stale
    inside that window.

    ### Rate limiting

    60 requests per IP per minute. Exceeding it returns **429 `rate_limited`**
    with a `Retry-After` header.

    ### CORS

    `/api/days/` sends `Access-Control-Allow-Origin: *`. The data is already
    public, so browser extensions and client-side dashboards can call it without
    a proxy. Simple `GET` requests need no preflight.

    ### Authentication

    None. Do not send credentials.
  contact:
    name: "@whendidtheyjoin.bsky.social"
    url: https://bsky.app/profile/whendidtheyjoin.bsky.social
  license:
    name: Public data, no warranty

servers:
  - url: https://www.inquizitriv.com/when-did-they-join
    description: Production

tags:
  - name: join-dates
    description: Account age lookups.
  - name: site
    description: Data about this site's own activity.

paths:

  /api/days/:
    get:
      operationId: getDaysSinceJoin
      summary: Days since a handle joined Bluesky
      description: |
        Returns the number of whole days (UTC) between the account's creation and
        now. An account created earlier today returns `0`. The value is clamped
        at zero, because clock skew against the PLC directory can otherwise place
        a minutes-old account slightly in the future.

        Lookups through this endpoint are intentionally **not** recorded in the
        site's "Recent Lookups" panel or its lookup counters, so polling a
        follower list will not pollute the site's own statistics.
      tags: [join-dates]
      parameters:
        - name: handle
          in: query
          required: true
          description: |
            A Bluesky handle, with or without a leading `@`. Case-insensitive;
            normalized to lowercase before resolution.
          schema:
            type: string
            maxLength: 254
          examples:
            plain:
              value: alice.bsky.social
            withAt:
              value: "@alice.bsky.social"
            customDomain:
              value: example.com
        - name: window
          in: query
          required: false
          description: |
            Threshold in days for `is_new_account`. Does not affect
            `days_since_joined`.
          schema:
            type: integer
            minimum: 1
            maximum: 365
            default: 7
      responses:
        "200":
          description: Join date resolved.
          headers:
            Cache-Control:
              description: Always `public, max-age=900`.
              schema:
                type: string
            Access-Control-Allow-Origin:
              description: Always `*`.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DaysSinceJoin"
              examples:
                newAccount:
                  summary: Joined today
                  value:
                    error: false
                    handle: alice.bsky.social
                    did: did:plc:z72i7hdynmk6r22z27h6tvur
                    days_since_joined: 0
                    joined_at: "2026-08-29T06:41:03.000Z"
                    join_date: "August 29, 2026"
                    join_date_source: plc
                    is_new_account: true
                    new_account_window_days: 7
                    checked_at: "2026-08-29T09:15:00+00:00"
                establishedAccount:
                  summary: Long-standing account
                  value:
                    error: false
                    handle: jay.bsky.social
                    did: did:plc:oky5czdrnfjpqslsw2a5iclo
                    days_since_joined: 1247
                    joined_at: "2023-04-03T18:22:41.000Z"
                    join_date: "April 03, 2023"
                    join_date_source: plc
                    is_new_account: false
                    new_account_window_days: 7
                    checked_at: "2026-08-29T09:15:00+00:00"
        "400":
          description: |
            Bad request — `missing_handle`, `invalid_handle`, or `invalid_window`.
          headers:
            Cache-Control:
              description: Always `no-store`.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
              examples:
                missingHandle:
                  value:
                    error: true
                    error_code: missing_handle
                    error_message: "Pass a Bluesky handle as ?handle=alice.bsky.social"
                invalidHandle:
                  value:
                    error: true
                    error_code: invalid_handle
                    error_message: "Not a valid Bluesky handle: not a handle"
                invalidWindow:
                  value:
                    error: true
                    error_code: invalid_window
                    error_message: "window must be between 1 and 365 days."
        "404":
          description: The handle does not resolve upstream.
          headers:
            Cache-Control:
              description: Always `no-store`.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
              example:
                error: true
                error_code: not_found
                error_message: "User not found: @nobody.bsky.social"
        "422":
          description: |
            The account resolves, but no authoritative join date exists — almost
            always a `did:web` account with no PLC audit log. Reported as unknown
            rather than guessed at.
          headers:
            Cache-Control:
              description: Always `no-store`.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
              example:
                error: true
                error_code: join_date_unavailable
                error_message: "No authoritative join date is available for @example.com."
        "429":
          description: Over 60 requests per minute from this IP.
          headers:
            Retry-After:
              description: Seconds to wait before retrying.
              schema:
                type: integer
                example: 60
            Cache-Control:
              description: Always `no-store`.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
              example:
                error: true
                error_code: rate_limited
                error_message: "Rate limit exceeded (60 requests per minute)."

  /api/recent/:
    get:
      operationId: getRecentLookups
      summary: Recent lookups on the site
      description: |
        The ten most recent handles looked up through the website's own
        interface. Traffic through `/api/days/` is excluded by design, so this
        reflects human visitors rather than bots.

        Note that this endpoint does **not** currently send CORS or cache
        headers, so it is server-to-server only unless that changes.
      tags: [site]
      responses:
        "200":
          description: Up to ten recent lookups, newest first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RecentLookups"
              example:
                lookups:
                  - handle: alice.bsky.social
                    display_name: Alice
                    avatar_url: https://cdn.bsky.app/img/avatar/plain/did:plc:abc/xyz@jpeg
                    join_date: "August 29, 2026"
                  - handle: bob.bsky.social
                    display_name: null
                    avatar_url: null
                    join_date: "January 12, 2024"

components:

  schemas:

    DaysSinceJoin:
      type: object
      required:
        - error
        - handle
        - days_since_joined
        - is_new_account
        - new_account_window_days
        - checked_at
      properties:
        error:
          type: boolean
          enum: [false]
          description: Always `false` on this schema. Discriminates success from `ApiError`.
        handle:
          type: string
          description: The resolved handle as the upstream profile reports it.
          example: alice.bsky.social
        did:
          type: string
          nullable: true
          description: The account's DID.
          example: did:plc:z72i7hdynmk6r22z27h6tvur
        days_since_joined:
          type: integer
          minimum: 0
          description: |
            Whole days, UTC, clamped at zero. `0` means the account was created
            earlier today. Recomputed per request, so it is never stale.
          example: 3
        joined_at:
          type: string
          nullable: true
          description: |
            Raw creation timestamp as received upstream. Usually RFC 3339 with a
            `Z` suffix, but it is passed through unmodified — parse defensively
            rather than assuming a fixed shape.
          example: "2026-08-26T14:02:11.000Z"
        join_date:
          type: string
          nullable: true
          description: |
            Human-readable form of `joined_at`, formatted `%B %d, %Y`. Falls back
            to the first ten characters of the raw value if it cannot be parsed.
          example: "August 26, 2026"
        join_date_source:
          type: string
          nullable: true
          enum: [plc, profile_record]
          description: |
            `plc` — from the PLC directory audit log; authoritative.
            `profile_record` — self-asserted `createdAt` on the profile record;
            weaker. Check this before treating a date as trustworthy.
          example: plc
        is_new_account:
          type: boolean
          description: "`days_since_joined <= window`."
          example: true
        new_account_window_days:
          type: integer
          minimum: 1
          maximum: 365
          description: The window actually applied, echoed back.
          example: 7
        checked_at:
          type: string
          format: date-time
          description: When the server computed this response.
          example: "2026-08-29T09:15:00+00:00"

    ApiError:
      type: object
      required:
        - error
        - error_code
        - error_message
      properties:
        error:
          type: boolean
          enum: [true]
        error_code:
          type: string
          enum:
            - missing_handle
            - invalid_handle
            - invalid_window
            - not_found
            - join_date_unavailable
            - rate_limited
          description: |
            Stable machine-readable code. Branch on this, not on
            `error_message`, which is prose and may be reworded.
        error_message:
          type: string
          description: Human-readable explanation. Not a stable contract.

    RecentLookup:
      type: object
      required:
        - handle
        - join_date
      properties:
        handle:
          type: string
          example: alice.bsky.social
        display_name:
          type: string
          nullable: true
        avatar_url:
          type: string
          format: uri
          nullable: true
        join_date:
          type: string
          description: Formatted date string, `%B %d, %Y`.
          example: "August 26, 2026"

    RecentLookups:
      type: object
      required: [lookups]
      properties:
        lookups:
          type: array
          maxItems: 10
          items:
            $ref: "#/components/schemas/RecentLookup"
