openapi: 3.0.3
info:
  title: AlphaFlow API
  version: "2.0"
  description: >
    Solana wallet intelligence, sybil detection, token risk, and fund-flow
    tracing. Every path/status code in this spec was verified against the
    actual route source in apps/web/src/app/api — not written from memory.
    /api/api-keys is session-authenticated (dashboard login) and is not
    part of this Bearer-key API surface, so it's excluded below.

    Every success response is wrapped as { "data": ... }, with pagination
    or other response context under a sibling "meta" key when present —
    data is always exactly the resource you asked for. Errors are
    unchanged: { "error": "..." } with a non-2xx status. The one exception
    is the CSV format of the sybil report endpoint, which returns a raw
    CSV body, not JSON. This is a breaking change from v1.0 of this API,
    which returned each endpoint's payload unwrapped.
servers:
  - url: https://getalphaflow.xyz
security:
  - bearerAuth: []
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: 'Pass as: Authorization: Bearer af_...'
  schemas:
    Error:
      type: object
      properties:
        error:
          type: string
    SuccessEnvelope:
      type: object
      required: [data]
      description: >
        The shape of every JSON success response. `data` is the resource
        or collection itself. `meta` is present on responses that carry
        pagination or other response context (count, total, limit, offset,
        cursor, nextCursor, minScore, etc) — never mixed into `data`.
      properties:
        data: {}
        meta:
          type: object
    AsyncJobAccepted:
      type: object
      required: [data]
      description: 202 Accepted body for an enqueued async job.
      properties:
        data:
          type: object
          properties:
            jobId: { type: string }
            status: { type: string, enum: [pending] }
            poll: { type: string, description: Path to poll for the result }
  responses:
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: Key revoked, or tier doesn't permit this endpoint
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: Daily rate limit exceeded for this key's tier
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Wallet, job, or cluster not found — job/cluster lookups are ownership-scoped, so another account's id also reads as 404
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    OK:
      description: Success
      content:
        application/json:
          schema: { $ref: '#/components/schemas/SuccessEnvelope' }
    Accepted:
      description: Job accepted and queued — data carries jobId and poll, not a result yet
      content:
        application/json:
          schema: { $ref: '#/components/schemas/AsyncJobAccepted' }
  parameters:
    Address:
      name: address
      in: path
      required: true
      schema: { type: string }
      description: Solana wallet address (base58)
    JobId:
      name: id
      in: path
      required: true
      schema: { type: string }
    ClusterId:
      name: clusterId
      in: path
      required: true
      schema: { type: string }
paths:
  /api/health:
    get:
      summary: Platform health check
      security: []
      description: Unauthenticated. Checks DB connectivity and reports latency.
      responses:
        '200': { $ref: '#/components/responses/OK' }
        '503': { description: 'Degraded — database unreachable, data.status is "degraded"' }

  /api/wallet/{address}:
    get:
      summary: Wallet score and stats
      parameters:
        - $ref: '#/components/parameters/Address'
      responses:
        '200': { $ref: '#/components/responses/OK' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/wallets/top:
    get:
      summary: Top-scoring graduated wallets
      description: >
        Offset-paginated (not cursor — score ordering drifts as wallets
        get rescored daily, so a cursor would skip/repeat entries under
        that churn). meta carries total, limit, offset, count, minScore.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
        - name: minScore
          in: query
          schema: { type: integer }
      responses:
        '200': { $ref: '#/components/responses/OK' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/explorer/wallet/{address}:
    get:
      summary: Full wallet intelligence profile (Explorer UI's data source)
      description: >
        Same underlying data as GET /api/wallet/{address}/intelligence,
        kept as a stable path for the dashboard UI; new integrations
        should prefer the /wallet/{address}/intelligence path.
      parameters:
        - $ref: '#/components/parameters/Address'
      responses:
        '200': { $ref: '#/components/responses/OK' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/wallet/{address}/intelligence:
    get:
      summary: Full wallet intelligence profile
      description: >
        Reason codes, reputation, risk, fund flow, relationships, token
        activity, historical outcomes — one call. Same underlying function
        as GET /api/explorer/wallet/{address} (kept for backward
        compatibility).
      parameters:
        - $ref: '#/components/parameters/Address'
      responses:
        '200': { $ref: '#/components/responses/OK' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/wallet/{address}/reputation:
    get:
      summary: Allowlist/gating recommendation for this wallet
      description: Returns allow / review / block. Same function as GET /api/wallet-reputation/{address} (deprecated, kept for backward compatibility).
      parameters:
        - $ref: '#/components/parameters/Address'
      responses:
        '200': { $ref: '#/components/responses/OK' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/wallet-reputation/{address}:
    get:
      summary: DEPRECATED — allowlist/gating recommendation for this wallet
      description: >
        Superseded by GET /api/wallet/{address}/intelligence, which returns
        this same reputation view alongside the rest of a wallet's
        profile. Not scheduled for removal — every existing field is
        still returned, with an additive deprecated/deprecationNote/
        successor marker in data.
      parameters:
        - $ref: '#/components/parameters/Address'
      responses:
        '200': { $ref: '#/components/responses/OK' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/wallet/{address}/history:
    get:
      summary: This wallet's early-buy trading history
      description: Offset-paginated. meta carries total, limit, offset, count, address.
      parameters:
        - $ref: '#/components/parameters/Address'
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 100 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        '200': { $ref: '#/components/responses/OK' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/wallet/{address}/outcomes:
    get:
      summary: Outcomes of every AlphaFlow alert fired on this wallet
      parameters:
        - $ref: '#/components/parameters/Address'
      responses:
        '200': { $ref: '#/components/responses/OK' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/wallet/{address}/discovery:
    get:
      summary: Where this wallet currently sits in the discovery pipeline
      parameters:
        - $ref: '#/components/parameters/Address'
      responses:
        '200': { $ref: '#/components/responses/OK' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/wallet/{address}/sybil:
    get:
      summary: Whether this wallet has appeared in a prior sybil cluster
      description: >
        Read-only, and scoped to the REQUESTER's own past /api/sybil/check
        jobs — does not run new detection, and does not expose another
        account's clustering results. For new detection, see POST
        /api/sybil/check.
      parameters:
        - $ref: '#/components/parameters/Address'
      responses:
        '200': { $ref: '#/components/responses/OK' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/wallet/{address}/token-risk:
    get:
      summary: This wallet's own rug exposure from tokens it has bought
      description: >
        NOT the same as GET /api/token-risk/{mint}, which scans a specific
        token's mint/LP/holder risk vectors. This endpoint aggregates the
        calling wallet's own history instead.
      parameters:
        - $ref: '#/components/parameters/Address'
      responses:
        '200': { $ref: '#/components/responses/OK' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/wallet/{address}/fundflow:
    get:
      summary: Trace outbound SOL fund flow from this wallet (GET convenience)
      description: >
        Synchronous — requires Developer tier or above (on-chain lookups
        per call). Results are cached in-memory for 5 minutes per
        address, so repeat calls within that window don't re-walk the
        chain. For the async job version, see POST /api/fundflow/trace.
      parameters:
        - $ref: '#/components/parameters/Address'
      responses:
        '200': { $ref: '#/components/responses/OK' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /api/fundflow/trace:
    post:
      summary: Enqueue a fund-flow trace from an arbitrary address
      description: >
        For exploit/rug tracing where the root address isn't necessarily
        an AlphaFlow-scored wallet. Requires Developer tier or above.
        Async — poll GET /api/fundflow/jobs/{id} for the result.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [address]
              properties:
                address: { type: string }
                sinceTimestamp: { type: number }
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { description: Missing or malformed address }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /api/fundflow/jobs/{id}:
    get:
      summary: Poll a fund-flow trace job
      description: Ownership-scoped to whoever enqueued it. data.status is pending/processing/complete/failed.
      parameters:
        - $ref: '#/components/parameters/JobId'
      responses:
        '200': { $ref: '#/components/responses/OK' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/sybil/check:
    post:
      summary: Enqueue sybil/airdrop-farm clustering on a batch of addresses
      description: >
        Requires Developer tier or above. Max 100 addresses/request on
        Developer tier, 500 on Growth/Internal. Max 5 concurrent jobs
        per requester. Async — poll GET /api/sybil/jobs/{id}.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [addresses]
              properties:
                addresses:
                  type: array
                  items: { type: string }
                  maxItems: 500
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { description: 'Invalid body, malformed addresses, or over the tier''s address cap' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { description: 'Daily rate limit, or the 5-concurrent-job cap, exceeded' }

  /api/sybil/jobs:
    get:
      summary: List your past sybil-check jobs
      description: >
        Cursor-paginated, newest first. Pass the previous response's
        meta.nextCursor as ?cursor= for the next page. meta also carries
        count.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - name: cursor
          in: query
          schema: { type: string }
      responses:
        '200': { $ref: '#/components/responses/OK' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/sybil/jobs/{id}:
    get:
      summary: Poll a sybil-check job
      description: >
        Ownership-scoped. data.status is pending/processing/complete/failed.
        On complete: clusters (with per-member evidence receipts),
        flaggedCount, knownFarmers (cross-job memory hits), unclustered.
      parameters:
        - $ref: '#/components/parameters/JobId'
      responses:
        '200': { $ref: '#/components/responses/OK' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/sybil/jobs/{id}/report:
    get:
      summary: Forwardable report for a completed sybil-check job
      description: >
        Two formats. Default (JSON): a summary shape wrapped in the usual
        { data } envelope — confidence bands, per-cluster decisions, a
        plain-English summary paragraph. ?format=csv: one row per flagged
        address, returned as a raw CSV body (NOT wrapped in JSON — this
        is the one exception to the envelope). Non-complete jobs return
        the same status shape as the poll endpoint, so one URL works
        throughout the job's lifecycle.
      parameters:
        - $ref: '#/components/parameters/JobId'
        - name: format
          in: query
          schema: { type: string, enum: [csv] }
          description: Omit for JSON; "csv" for the raw CSV export.
      responses:
        '200':
          description: >
            JSON summary (default, wrapped in the { data } envelope) or a
            raw text/csv body when ?format=csv is passed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
            text/csv:
              schema: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/sybil/clusters/{clusterId}/decision:
    post:
      summary: Record a reviewer decision on a cluster
      description: >
        Append-only — always creates a new decision record, never updates
        one; a changed mind is a new decision. Ownership-scoped through
        the cluster's parent job.
      parameters:
        - $ref: '#/components/parameters/ClusterId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [decision]
              properties:
                decision: { type: string, enum: [APPROVED, REJECTED, NEEDS_REVIEW] }
                reason: { type: string }
      responses:
        '200': { $ref: '#/components/responses/OK' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/sybil/clusters/{clusterId}/note:
    post:
      summary: Attach a free-text note to a cluster
      description: Ownership-scoped through the cluster's parent job, same as decision.
      parameters:
        - $ref: '#/components/parameters/ClusterId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string }
      responses:
        '200': { $ref: '#/components/responses/OK' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/sybil/clusters/{clusterId}/history:
    get:
      summary: Combined decision + note history for a cluster, newest first
      description: >
        data is the merged timeline (each entry tagged decision/note).
        meta carries clusterId, currentDecision, currentDecisionReason.
      parameters:
        - $ref: '#/components/parameters/ClusterId'
      responses:
        '200': { $ref: '#/components/responses/OK' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/consolidation/scan:
    post:
      summary: Enqueue a trace-forward consolidation scan
      description: >
        Given known claimant wallets from a past airdrop, finds sink
        addresses they swept funds into. Requires Developer tier or
        above. Max 100 claimants on Developer tier, 500 on Growth/Internal.
        Async — poll GET /api/consolidation/jobs/{id}.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [addresses]
              properties:
                addresses:
                  type: array
                  items: { type: string }
                  maxItems: 500
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { description: 'Invalid body, or over the tier''s claimant cap' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /api/consolidation/jobs/{id}:
    get:
      summary: Poll a consolidation scan job
      description: >
        Ownership-scoped. On complete: sinks (address, inflowCount,
        totalSol, fromClaimants, knownDestination), unresolved.
      parameters:
        - $ref: '#/components/parameters/JobId'
      responses:
        '200': { $ref: '#/components/responses/OK' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/outcomes/import:
    post:
      summary: Import customer-reported outcomes for a sybil-check job
      description: >
        Append-only — every outcome becomes a new observation row, never
        an update. Ownership-scoped: jobId must belong to the requester.
        Expects zero real submissions until a data-sharing term is agreed
        with a customer; the path exists ahead of that so the ask is
        concrete.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [jobId, outcomes]
              properties:
                jobId: { type: string }
                outcomes:
                  type: array
                  items:
                    type: object
                    required: [address, outcome]
                    properties:
                      address: { type: string }
                      outcome: { type: string }
                      detail: { type: object }
      responses:
        '200': { $ref: '#/components/responses/OK' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/token-risk/{mint}:
    get:
      summary: Mint authority, LP lock, and holder-concentration scan for a token
      description: Requires Developer tier or above.
      parameters:
        - name: mint
          in: path
          required: true
          schema: { type: string }
        - name: lpMint
          in: query
          schema: { type: string }
        - name: deployer
          in: query
          schema: { type: string }
      responses:
        '200': { $ref: '#/components/responses/OK' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /api/discovery:
    get:
      summary: Platform-wide discovery pipeline state
      description: Trending wallets, recently promoted, candidates, top early buyers, recent token launches, statistics.
      responses:
        '200': { $ref: '#/components/responses/OK' }
        '401': { $ref: '#/components/responses/Unauthorized' }
