openapi: 3.0.3
info:
  title: Vybe Mobile API
  version: 1.0.0
  contact:
    name: Vybe Engineering
    url: https://vybe.finance
  description: |
    **Canonical HTTP API for the Vybe mobile app.**

    **Interactive docs:** [https://vybe.finance/mobiledocs](https://vybe.finance/mobiledocs)  
    **This spec:** [https://vybe.finance/mobiledocs/openapi.yaml](https://vybe.finance/mobiledocs/openapi.yaml)

    **Mobile v1 (ship this):** login, claim `@you`, balance, notifications, pay `@name`,
    open `/r/`, settle. Details: `docs/MOBILE_V1.md`.

    **Later / desk / parked:** service CMS, Collect keys, webhooks, CSV, Business billing
    UI, fiat KYC, virtual card tab. Tagged below. Parked list: `docs/ROADMAP_PARKED.md`.

    This spec still lists every HTTP route so later screens do not invent new paths.

    ---

    ## Base URLs

    | Environment | Base URL |
    |-------------|----------|
    | Production | `https://vybe.finance` |
    | Local | `http://localhost:3000` |

    Override with `NEXT_PUBLIC_SITE_URL` on the server. There is **no separate API host** —
    paths are relative to the site origin (e.g. `GET https://vybe.finance/api/profile`).

    ---

    ## Authentication model (read carefully)

    ### Crossmint = identity + wallet

    1. User signs in with **Crossmint Auth** (email / Google / X) via the Crossmint SDK.
    2. After login, Crossmint returns a stable user id — treat this as **`crossmintId`**
       (JWT `sub`). Persist the EVM address via `PATCH /api/profile` (`walletAddress`).
    3. **Wallet balance, send, and transfer history are NOT Vybe HTTP routes.**
       Use the **Crossmint Wallet SDK**.

    ### Owner routes (v1 native must send JWT)

    Send **both**:

    - `Authorization: Bearer <Crossmint JWT>` (from Auth SDK / secure storage)
    - `crossmintId` in query (GET) or JSON body (writes) — must match JWT `sub`

    Vybe verifies the JWT against Crossmint JWKS. If the JWT `sub` disagrees with
    `crossmintId` → `403` `{ "code": "AUTH_MISMATCH" }`.

    Same-origin web also accepts Crossmint cookies (`crossmint-jwt`, `crossmint-session`).
    Until `VYBE_REQUIRE_SESSION=true` on the server, a matching `crossmintId` without JWT
    still works (legacy web). **Native v1 should not rely on that.**

    Missing JWT when session is required → `401` `{ "code": "AUTH_REQUIRED" }`.

    Never put `crossmintId` in public deep links.

    ### Other auth flavors

    | Flavor | When |
    |--------|------|
    | Public (no auth) | Username lookup, payment request by `publicId`, settle by txHash, agent Collect catalog |
    | `Authorization: Bearer vybe_col_…` | Collect **business** API (server keys) — desk, not mobile v1 |
    | `Authorization: Bearer vapt_…` | Agent spend pay (`POST /api/agent/pay`) — agents, not mobile UI |
    | Bridge webhook HMAC | Server-only — **do not call from mobile** |
    | `VYBE_USER_NOTIFY_WEBHOOK_URL` | Server → your push/email worker when agent pay needs approval |

    ---

    ## Critical mobile architecture

    ```
    ┌─────────────────┐     crossmintId      ┌──────────────────┐
    │ Crossmint Auth  │ ───────────────────► │  Vybe /api/*     │
    │ + Wallet SDK    │                      │  (Mongo-backed)  │
    └────────┬────────┘                      └──────────────────┘
             │
             │ balances / send / transfers
             ▼
    ┌─────────────────┐
    │ Crossmint chain │  USDC/USDT on base | ethereum | bsc | solana
    └─────────────────┘
    ```

    | Capability | Source of truth |
    |------------|-----------------|
    | Login / logout / session | Crossmint Auth SDK |
    | Smart wallet create / address | Crossmint Wallet SDK |
    | Token balances | Crossmint Wallet SDK |
    | Send USDC/USDT to an address | Crossmint Wallet SDK |
    | Activity (on-chain transfers) | Crossmint transfers + optionally merge paid `agent_collect` requests |
    | @username, friends, invoices, services, notifs | **This API** |

    ---

    ## Conventions

    - **Content-Type:** `application/json` for bodies.
    - **Errors:** `{ "message": "Human-readable string" }` (+ occasional `code`).
    - **Amounts:** decimal strings, e.g. `"150.00"` (not floats).
    - **Usernames:** lowercase, 3–20 chars, `[a-z0-9_]`, no leading `@` in API fields
      (UI may show `@alex`; strip `@` before sending).
    - **Timestamps:** ISO-8601 strings when serialized from dates.
    - **Tokens:** `usdc` \| `usdt`.
    - **EVM chains for settle/deposit:** `base` \| `ethereum` \| `bsc`.
    - **Default login chain:** `base` (`NEXT_PUBLIC_CROSSMINT_CHAIN`).

    ---

    ## Recommended mobile screen → API map

    ### V1 daily loop

    | Screen / flow | Calls |
    |---------------|-------|
    | Splash / session | Crossmint Auth restore + store JWT |
    | Onboarding claim @you | `GET /api/profile?check=` → `POST /api/profile` |
    | Home balance | Crossmint balances + `GET /api/profile?crossmintId=` |
    | Search users | `GET /api/profile?q=` |
    | Add friend | `POST /api/friends` |
    | Friends list | `GET /api/friends` |
    | Pay @name | PIN verify → Crossmint transfer to friend’s `walletAddress` |
    | Create / request (optional) | `GET /api/payment-requests?quoteAmount=` then `POST` |
    | Pay invoice (Vybe wallet) | PIN verify → Transfer → `PATCH /api/payment-requests` with `approvalPinToken` |
    | Pay invoice (external tx) | `POST /api/payment-requests/settle` |
    | Open public invoice | `GET /api/payment-requests?publicId=` |
    | Notifications | `GET/PATCH /api/notifications` |
    | Agent spend approvals | PIN verify → Approve; Decline needs no PIN |
    | Payment PIN | `GET/POST /api/security/approval-pin` — required before any send |
    | Agentic page feed | `GET /api/agent/payments` (spend + collect, summary, pagination) |
    | Card waitlist | `POST /api/interest` `{ interest: "card" }` |

    ### Later / web desk (do not block v1)

    | Screen / flow | Calls |
    |---------------|-------|
    | Service page editor | `GET/PUT /api/services` |
    | Public book & pay | `GET /api/services?username=` → `POST /api/services` |
    | Business plan | `GET/POST /api/billing` |
    | Bank / fiat | `/api/fiat/*` (parked) |
    | Collect toggle / keys | `PUT /api/services`, `/api/collect/v1/*` |
    | Issue / revoke VAPT | `POST/DELETE /api/vapt/credentials` · list `GET …?includeRevoked=true` for blocked |

    ---

    ## Deep links (web parity — implement in mobile)

    | Path | Meaning |
    |------|---------|
    | `/s/{username}` | Public service page |
    | `/r/{publicId}` | Payment request checkout |
    | `/pay/{username}` | Pay user |
    | `/invite/{username}` | Invite / add friend |
    | `/a/{username}` | Agent Collect docs (builders) |
    | `/login?claim=` | Prefill username claim |
    | `/login?request=` | Open payment request after login |

servers:
  - url: https://vybe.finance
    description: Production
  - url: http://localhost:3000
    description: Local development

# Owner routes: Bearer JWT + crossmintId. Public routes use security: [].
security:
  - BearerAuth: []
    CrossmintId: []
  - CrossmintId: []
  - {}

tags:
  - name: GettingStarted
    description: Read first — auth, wallet, conventions
  - name: Profile
    description: Mobile v1 — claim username, load me, search users, update profile
  - name: Friends
    description: Mobile v1 — one-way friends list for pay-by-@name
  - name: PaymentRequests
    description: Mobile v1 — invoices / payment links / mark paid / settle
  - name: Notifications
    description: Mobile v1 — in-app notification feed
  - name: Interest
    description: Parked waitlists (card, rails) — card waitlist OK from Settings
  - name: Billing
    description: Later / web desk — Free vs Business plan
  - name: Services
    description: Later / web desk editor. Public GET+checkout OK if mobile opens /s/
  - name: Fiat
    description: Parked — Bridge KYC, virtual account, offramp (regional)
  - name: Collect
    description: Later / desk + builders — catalog is public; keys are desk-only
  - name: AgentSpend
    description: |
      Golive 1 VAPT — mobile consumes **pending approvals** + notifications.
      Issue/revoke VAPT on web Settings; agents call `POST /api/agent/pay` with `vapt_…`.
      Docs: `docs/VAPT.md`, `docs/GOLIVE.md`.
  - name: Security
    description: |
      Mobile v1 — 6-digit payment PIN. Required before any asset leaves the wallet
      (P2P send, invoice pay, agent approval). Decline does not need a PIN.

paths:
  /api/profile:
    get:
      tags: [Profile]
      summary: Load profile, check username, or search users
      description: |
        **Exactly one** query mode should be used:

        | Param | Mode |
        |-------|------|
        | `crossmintId` | Load **my** profile (after Crossmint login) |
        | `username` | Public profile by @username |
        | `check` | Availability check before claim (also returns premium pricing) |
        | `q` | Typeahead search (min ~1 char; returns matches) |
      operationId: getProfile
      parameters:
        - $ref: '#/components/parameters/crossmintIdQuery'
        - name: username
          in: query
          schema:
            type: string
            example: alex
        - name: check
          in: query
          description: Username to check for availability
          schema:
            type: string
            example: alex
        - name: q
          in: query
          description: Search query for usernames / names
          schema:
            type: string
            example: ale
      responses:
        '200':
          description: Success (shape depends on query mode)
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/MyProfileResponse'
                  - $ref: '#/components/schemas/PublicProfileResponse'
                  - $ref: '#/components/schemas/UsernameCheckResponse'
                  - $ref: '#/components/schemas/ProfileSearchResponse'
              examples:
                me:
                  summary: GET ?crossmintId=
                  value:
                    username: alex
                    avatar: leaf
                    walletAddress: '0x8f2c00000000000000000000000000000000c21a'
                    plan: free
                    planExpiresAt: null
                    displayName: Alex Okoro
                    tagline: Design critiques
                    firstName: Alex
                    lastName: Okoro
                    premiumUsernamePaid: false
                    approvalPinSet: true
                check:
                  summary: GET ?check=alex
                  value:
                    available: true
                    premium: false
                    premiumPriceUsd: 5
                    treasuryAddress: null
                search:
                  summary: GET ?q=ale
                  value:
                    results:
                      - username: alex
                        avatar: leaf
                        walletAddress: '0x8f2c00000000000000000000000000000000c21a'
                        crossmintId: usr_abc
                        firstName: Alex
                        lastName: Okoro
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'

    post:
      tags: [Profile]
      summary: Claim / create username (onboarding)
      description: |
        Creates the Vybe profile for a Crossmint user.

        - Usernames are unique and lowercased.
        - Short/premium names may return **402** with price + treasury — client pays
          treasury on-chain then retries with `premiumPaidConfirmed: true`.
        - Pass `walletAddress` when Crossmint wallet is ready (recommended at claim).
      operationId: claimProfile
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ClaimProfileRequest'
            example:
              crossmintId: usr_abc
              email: alex@example.com
              username: alex
              firstName: Alex
              lastName: Okoro
              avatar: leaf
              walletAddress: '0x8f2c00000000000000000000000000000000c21a'
      responses:
        '200':
          description: Profile already existed / updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ClaimProfileResponse'
        '201':
          description: Profile created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ClaimProfileResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '402':
          description: Premium username payment required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PremiumUsernameRequired'
        '409':
          description: Username reserved or taken
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorMessage'
        '500':
          $ref: '#/components/responses/ServerError'

    patch:
      tags: [Profile]
      summary: Update profile fields
      description: |
        Partial update. At least one mutable field besides `crossmintId` is required.
        Changing to a premium username may return **402** (same flow as claim).
      operationId: patchProfile
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PatchProfileRequest'
      responses:
        '200':
          description: Updated profile snapshot
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PatchProfileResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '402':
          description: Premium username payment required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PremiumUsernameRequired'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/ServerError'

  /api/billing:
    get:
      tags: [Billing]
      summary: Billing status for current user
      operationId: getBilling
      parameters:
        - $ref: '#/components/parameters/crossmintIdQueryRequired'
      responses:
        '200':
          description: Billing snapshot
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BillingStatus'
              example:
                plan: free
                planExpiresAt: null
                businessPriceUsd: 0
                businessFree: true
                premiumUsernamePriceUsd: 5
                treasuryAddress: null
                billingEnabled: true
                displayName: ''
                tagline: ''
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/ServerError'

    post:
      tags: [Billing]
      summary: Activate Vybe Business
      description: |
        When `businessFree` is true, activation is free (no on-chain pay).
        When paid, client must send funds to `treasuryAddress` then call with
        `paidConfirmed: true`.
      operationId: activateBusiness
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [crossmintId]
              properties:
                crossmintId:
                  type: string
                paidConfirmed:
                  type: boolean
                  description: Required when Business is not free
      responses:
        '200':
          description: Business activated
          content:
            application/json:
              schema:
                type: object
                properties:
                  plan:
                    type: string
                    enum: [business]
                  planExpiresAt:
                    type: string
                    format: date-time
                    nullable: true
                  businessPriceUsd:
                    type: number
                  businessFree:
                    type: boolean
                  message:
                    type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '503':
          description: Treasury not configured for paid Business
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorMessage'
        '500':
          $ref: '#/components/responses/ServerError'

  /api/friends:
    get:
      tags: [Friends]
      summary: List friends (paginated)
      description: |
        One-way friends list owned by `crossmintId`. Used for pay-by-@username
        and delivering payment requests to friends.
      operationId: listFriends
      parameters:
        - $ref: '#/components/parameters/crossmintIdQueryRequired'
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: limit
          in: query
          description: Page size (1–50)
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 10
      responses:
        '200':
          description: Friends page
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FriendsListResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/ServerError'

    post:
      tags: [Friends]
      summary: Add friend by username
      description: |
        Adds `friendUsername` to the caller's friends list.
        Creates a `friend_added` notification for the friend.
        Idempotent if already friends (200 with message).
      operationId: addFriend
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [crossmintId, friendUsername]
              properties:
                crossmintId:
                  type: string
                friendUsername:
                  type: string
                  description: With or without @; server strips @
                  example: jordan
            example:
              crossmintId: usr_abc
              friendUsername: jordan
      responses:
        '201':
          description: Friend added
          content:
            application/json:
              schema:
                type: object
                properties:
                  friend:
                    $ref: '#/components/schemas/Friend'
        '200':
          description: Already friends
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorMessage'
        '400':
          description: Validation / cannot add self / no username yet
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorMessage'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'

    delete:
      tags: [Friends]
      summary: Remove friend
      operationId: removeFriend
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [crossmintId, friendCrossmintId]
              properties:
                crossmintId:
                  type: string
                friendCrossmintId:
                  type: string
      responses:
        '200':
          description: Removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/ServerError'

  /api/payment-requests:
    get:
      tags: [PaymentRequests]
      summary: Quote fee, load one request, or list mine
      description: |
        **Modes (pick one):**

        1. **Fee quote** — `quoteAmount` (+ optional `feePayer`)
        2. **Public single** — `publicId` (no auth; for `/r/{id}` checkout)
        3. **Open series invoice** — `seriesId`
        4. **My list** — `crossmintId` (+ optional `source`, `status`, `format=csv`)

        CSV export requires Business plan.
      operationId: getPaymentRequests
      parameters:
        - name: quoteAmount
          in: query
          schema:
            type: string
            example: '150.00'
        - name: feePayer
          in: query
          schema:
            $ref: '#/components/schemas/FeePayer'
        - name: publicId
          in: query
          schema:
            type: string
            example: Vh3xR2mQ
        - name: seriesId
          in: query
          schema:
            type: string
        - name: crossmintId
          in: query
          schema:
            type: string
        - name: source
          in: query
          schema:
            $ref: '#/components/schemas/PaymentRequestSource'
        - name: status
          in: query
          schema:
            $ref: '#/components/schemas/PaymentRequestStatus'
        - name: format
          in: query
          description: Set to `csv` for Business CSV export
          schema:
            type: string
            enum: [csv]
      responses:
        '200':
          description: Quote, single request, or list
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/FeeQuoteResponse'
                  - type: object
                    properties:
                      request:
                        $ref: '#/components/schemas/PaymentRequest'
                  - type: object
                    properties:
                      requests:
                        type: array
                        items:
                          $ref: '#/components/schemas/PaymentRequest'
                      plan:
                        $ref: '#/components/schemas/VybePlan'
            text/csv:
              schema:
                type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          description: CSV requires Business
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorMessage'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'

    post:
      tags: [PaymentRequests]
      summary: Create payment request (invoice link)
      description: |
        Creates a pending payment request. Share `https://vybe.finance/r/{publicId}`.

        - Creator must have a claimed profile + `walletAddress`.
        - Pending request limits apply (`free` vs `business`).
        - `recurringInterval` requires Business.
        - `targetUsername` delivers to a friend (must be on friends list) + notification.
      operationId: createPaymentRequest
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreatePaymentRequestBody'
            example:
              crossmintId: usr_abc
              amount: '150.00'
              token: usdc
              memo: Design critique
              expiresInHours: 72
              feePayer: creator
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    $ref: '#/components/schemas/PaymentRequest'
                  deliveredToFriend:
                    type: boolean
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          description: Plan / friends / request limit
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ErrorMessage'
                  - type: object
                    properties:
                      code:
                        type: string
                        enum: [NOT_ON_FRIENDS_LIST, REQUEST_LIMIT]
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'

    patch:
      tags: [PaymentRequests]
      summary: Cancel request OR mark paid (Vybe wallet path)
      description: |
        Two body shapes:

        **Cancel (creator):**
        ```json
        { "action": "cancel", "publicId": "…", "crossmintId": "…" }
        ```

        **Mark paid (payer after Crossmint transfer):**
        ```json
        {
          "publicId": "…",
          "payerCrossmintId": "…",
          "payerUsername": "jordan",
          "feeCollectedOnChain": false,
          "approvalPinToken": "…"
        }
        ```

        If the payer has a payment PIN set, `approvalPinToken` is **required**
        (from `POST /api/security/approval-pin` `action: verify` or `set`).
        Recurring series may return `nextRequest` when a new invoice is spawned.
        You cannot mark your own request paid.
      operationId: patchPaymentRequest
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: '#/components/schemas/CancelPaymentRequestBody'
                - $ref: '#/components/schemas/MarkPaidPaymentRequestBody'
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    $ref: '#/components/schemas/PaymentRequest'
                  nextRequest:
                    $ref: '#/components/schemas/PaymentRequest'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          description: Missing / invalid payment PIN token
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ErrorMessage'
                  - type: object
                    properties:
                      code:
                        type: string
                        enum:
                          - approval_pin_required
                          - approval_pin_invalid
                          - AUTH_MISMATCH
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Invalid status / expired
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorMessage'
        '429':
          description: PIN locked after too many attempts
        '500':
          $ref: '#/components/responses/ServerError'

  /api/security/approval-pin:
    get:
      tags: [Security]
      summary: Payment PIN status
      description: |
        Returns whether the signed-in user has a 6-digit payment PIN set,
        lockout state, and remaining verify attempts.
      operationId: getApprovalPinStatus
      parameters:
        - $ref: '#/components/parameters/crossmintIdQuery'
      responses:
        '200':
          description: PIN status (never returns the PIN or hash)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApprovalPinStatus'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
    post:
      tags: [Security]
      summary: Set, change, or verify payment PIN
      description: |
        **Native send flow**

        1. `GET` this path — if `pinSet` is false, `action: set` (enter twice in UI)
        2. Before every outbound send / agent Approve, `action: verify`
        3. Use returned `approvalToken` as `approvalPinToken` on
           `PATCH /api/payment-requests` (mark paid)
        4. Then Crossmint `wallet.send`

        Token TTL is ~90 seconds. Decline / cancel does **not** need a PIN.
      operationId: postApprovalPin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ApprovalPinActionBody'
            examples:
              set:
                value:
                  action: set
                  crossmintId: usr_abc
                  pin: '482910'
              verify:
                value:
                  action: verify
                  crossmintId: usr_abc
                  pin: '482910'
              change:
                value:
                  action: change
                  crossmintId: usr_abc
                  currentPin: '482910'
                  newPin: '193847'
      responses:
        '200':
          description: Status plus `approvalToken` on set/verify
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApprovalPinStatus'
                  - type: object
                    properties:
                      approvalToken:
                        type: string
                      approvalTokenExpiresAt:
                        type: string
                        format: date-time
                      changed:
                        type: boolean
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          description: Wrong PIN or PIN not set
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ErrorMessage'
                  - type: object
                    properties:
                      code:
                        type: string
                        enum:
                          - approval_pin_invalid
                          - approval_pin_not_set
        '409':
          description: PIN already set — use change
        '429':
          description: Too many wrong attempts — locked
        '500':
          $ref: '#/components/responses/ServerError'

  /api/payment-requests/settle:
    post:
      tags: [PaymentRequests]
      summary: Settle with on-chain tx hash (public)
      description: |
        After MetaMask / external wallet sends exact USDC or USDT to the request
        `walletAddress`, POST the tx hash. Server verifies Transfer on Base,
        Ethereum, or BNB Chain (tries all rails if `chain` omitted).

        Use this when the payer is **not** paying from Crossmint Vybe wallet
        (or when you already have a confirmed txHash).
      operationId: settlePaymentRequest
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SettlePaymentRequestBody'
            example:
              publicId: Vh3xR2mQ
              txHash: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
              chain: base
              payerNote: metamask
      responses:
        '200':
          description: Paid (or already paid)
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    type: object
                    properties:
                      publicId:
                        type: string
                      status:
                        type: string
                        enum: [paid]
                      settlementTxHash:
                        type: string
                        nullable: true
                      paidAt:
                        type: string
                        format: date-time
                        nullable: true
        '400':
          description: Verification failed / invalid body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorMessage'
        '404':
          $ref: '#/components/responses/NotFound'
        '410':
          description: Expired
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorMessage'
        '500':
          $ref: '#/components/responses/ServerError'

  /api/services:
    get:
      tags: [Services]
      summary: Load service page (public or owner)
      description: |
        - `?username=` — **public** published page (404 if unpublished)
        - `?crossmintId=` — **owner** view (includes inactive offerings + webhook flags)
      operationId: getServices
      parameters:
        - name: username
          in: query
          schema:
            type: string
        - name: crossmintId
          in: query
          schema:
            type: string
      responses:
        '200':
          description: Service page
          content:
            application/json:
              schema:
                type: object
                properties:
                  page:
                    $ref: '#/components/schemas/ServicePage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'

    put:
      tags: [Services]
      summary: Create / update service page (owner)
      description: |
        Upserts the owner's service page. Publishing requires a wallet on profile
        and at least one active offering.
        Set `agentCollectEnabled: true` to accept AI agent payments (Vybe Collect).
      operationId: putServices
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpsertServicePageBody'
      responses:
        '200':
          description: Saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  page:
                    $ref: '#/components/schemas/ServicePage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'

    post:
      tags: [Services]
      summary: Book & pay — create checkout request for an offering
      description: |
        Public checkout. Creates a payment request with `source: service`.
        Client then pays via Vybe wallet / MetaMask / transfer using
        `/api/payment-requests` mark-paid or `/settle`.
      operationId: checkoutServiceOffering
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [username, offeringId]
              properties:
                username:
                  type: string
                  example: alex
                offeringId:
                  type: string
                  example: svc_9fQ2Kd
      responses:
        '201':
          description: Checkout intent created
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    type: object
                    required: [publicId, amount, token, memo, walletAddress]
                    properties:
                      publicId:
                        type: string
                      amount:
                        type: string
                      token:
                        $ref: '#/components/schemas/StableToken'
                      memo:
                        type: string
                      walletAddress:
                        type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'

  /api/notifications:
    get:
      tags: [Notifications]
      summary: List notifications (latest 30)
      operationId: listNotifications
      parameters:
        - $ref: '#/components/parameters/crossmintIdQueryRequired'
      responses:
        '200':
          description: Feed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotificationsResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/ServerError'

    post:
      tags: [Notifications]
      summary: Create notification (internal / rare)
      description: |
        Usually created server-side by friends / payment flows.
        Mobile typically does **not** need this for v1.
      operationId: createNotification
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateNotificationBody'
      responses:
        '201':
          description: Created
        '200':
          description: Skipped self-notify
          content:
            application/json:
              schema:
                type: object
                properties:
                  skipped:
                    type: boolean
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/ServerError'

    patch:
      tags: [Notifications]
      summary: Mark notifications read
      operationId: markNotificationsRead
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [crossmintId]
              properties:
                crossmintId:
                  type: string
                markAll:
                  type: boolean
                notificationIds:
                  type: array
                  items:
                    type: string
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/ServerError'

  /api/fiat/status:
    get:
      tags: [Fiat]
      summary: Fiat / KYC / rails status
      description: |
        Returns Bridge configuration mode, KYC state, virtual account summary,
        linked bank accounts, and recent transfers. Availability is **regional**.
      operationId: getFiatStatus
      parameters:
        - $ref: '#/components/parameters/crossmintIdQueryRequired'
      responses:
        '200':
          description: Status payload
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FiatStatusResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/ServerError'

    post:
      tags: [Fiat]
      summary: Start KYC / create Bridge customer
      operationId: startFiatKyc
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [crossmintId]
              properties:
                crossmintId:
                  type: string
                email:
                  type: string
                  format: email
      responses:
        '200':
          description: KYC links / sandbox ready
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/ServerError'

  /api/fiat/virtual-account:
    post:
      tags: [Fiat]
      summary: Create USD virtual deposit account
      description: |
        Requires KYC ready. Returns bank deposit details that credit USDC on Base
        into the user's wallet (partner conversion).
      operationId: createVirtualAccount
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [crossmintId, walletAddress]
              properties:
                crossmintId:
                  type: string
                walletAddress:
                  type: string
                  pattern: '^0x[a-fA-F0-9]{40}$'
      responses:
        '200':
          description: Virtual account details
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '403':
          description: KYC not ready
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorMessage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/ServerError'

  /api/fiat/offramp:
    get:
      tags: [Fiat]
      summary: Offramp quote (min $10)
      operationId: getOfframpQuote
      parameters:
        - name: amount
          in: query
          required: true
          schema:
            type: string
            example: '100.00'
      responses:
        '200':
          description: Quote
          content:
            application/json:
              schema:
                type: object
                properties:
                  quote:
                    type: object
                    additionalProperties: true
                  corridor:
                    type: string
                    example: usd_ach
                  provider:
                    type: string
        '400':
          $ref: '#/components/responses/BadRequest'

    post:
      tags: [Fiat]
      summary: Add bank account OR start offramp transfer
      description: |
        - `action: "add_bank"` + bank fields → create external account
        - Default: start offramp with `externalAccountId`, `amount`, `walletAddress`
      operationId: postOfframp
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [crossmintId]
              properties:
                crossmintId:
                  type: string
                action:
                  type: string
                  enum: [add_bank]
                walletAddress:
                  type: string
                amount:
                  type: string
                externalAccountId:
                  type: string
              additionalProperties: true
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '403':
          description: KYC required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorMessage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/ServerError'

  /api/fiat/sandbox/simulate-deposit:
    post:
      tags: [Fiat]
      summary: Sandbox-only simulate deposit
      description: Dev/test helper. Blocked when Bridge is live unless explicitly allowed.
      operationId: simulateFiatDeposit
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [crossmintId]
              properties:
                crossmintId:
                  type: string
                amount:
                  type: string
                  example: '50.00'
      responses:
        '200':
          description: Simulated
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '403':
          $ref: '#/components/responses/Forbidden'
        '400':
          $ref: '#/components/responses/BadRequest'

  /api/interest:
    post:
      tags: [Interest]
      summary: Join a waitlist
      description: Requires `crossmintId` **or** `email`.
      operationId: postInterest
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [interest]
              properties:
                interest:
                  type: string
                  enum: [onramp, offramp, card, developer, agent_collect, blog]
                crossmintId:
                  type: string
                email:
                  type: string
                  format: email
                volume:
                  type: string
                note:
                  type: string
      responses:
        '201':
          description: Recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/ServerError'

  /api/collect/v1/credentials:
    get:
      tags: [Collect]
      summary: List Collect API key prefixes (owner)
      operationId: listCollectCredentials
      parameters:
        - $ref: '#/components/parameters/crossmintIdQueryRequired'
      responses:
        '200':
          description: Credentials (prefixes only — full key never returned again)
          content:
            application/json:
              schema:
                type: object
                properties:
                  credentials:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                        keyPrefix:
                          type: string
                        label:
                          type: string
                          nullable: true
                        username:
                          type: string
                        lastUsedAt:
                          type: string
                          format: date-time
                          nullable: true
                        createdAt:
                          type: string
                          format: date-time
        '400':
          $ref: '#/components/responses/BadRequest'

    post:
      tags: [Collect]
      summary: Create Collect API key (shown once)
      operationId: createCollectCredential
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [crossmintId]
              properties:
                crossmintId:
                  type: string
                label:
                  type: string
      responses:
        '201':
          description: Key created — store `apiKey` immediately
          content:
            application/json:
              schema:
                type: object
                properties:
                  credential:
                    type: object
                    additionalProperties: true
                  apiKey:
                    type: string
                    description: Full `vybe_col_…` secret — shown once
                  warning:
                    type: string
        '400':
          $ref: '#/components/responses/BadRequest'

    delete:
      tags: [Collect]
      summary: Revoke Collect API key
      operationId: revokeCollectCredential
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [crossmintId, credentialId]
              properties:
                crossmintId:
                  type: string
                credentialId:
                  type: string
      responses:
        '200':
          description: Revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  revoked:
                    type: boolean

  /api/collect/v1/webhook:
    get:
      tags: [Collect]
      summary: Get Collect webhook config
      operationId: getCollectWebhook
      parameters:
        - name: crossmintId
          in: query
          schema:
            type: string
        - name: reveal
          in: query
          description: Reveal secret only when authorized with Collect Bearer key
          schema:
            type: string
            enum: ['1']
      security:
        - CollectApiKey: []
        - {}
      responses:
        '200':
          description: Webhook config
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true

    put:
      tags: [Collect]
      summary: Register / rotate Collect webhook
      description: |
        Events: `payment.paid`, `payment.expired`.
        Signature header on deliveries: `X-Vybe-Signature: sha256=…`
      operationId: putCollectWebhook
      security:
        - CollectApiKey: []
        - {}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                  description: Must be https
                rotateSecret:
                  type: boolean
                crossmintId:
                  type: string
      responses:
        '200':
          description: Saved
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true

  /api/agent/collect/{username}:
    get:
      tags: [Collect]
      summary: Public agent catalog for a seller
      description: |
        Machine-readable offerings when page is published + Collect enabled.
        CORS `*`. Useful for demos / agent integrations; optional in consumer mobile.
      operationId: getAgentCatalog
      parameters:
        - $ref: '#/components/parameters/usernamePath'
      responses:
        '200':
          description: Catalog
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentCollectCatalog'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/agent/collect/{username}/{offeringId}:
    get:
      tags: [Collect]
      summary: Create agent payment intent / return 402
      description: |
        Without payment headers → **402 Payment Required** with `accepts[]` and
        `PAYMENT-REQUIRED` header. Creates pending `agent_collect` payment request.

        With `PAYMENT-SIGNATURE` / `X-PAYMENT` → facilitator settle → 200 paid.
      operationId: agentCollectOfferingGet
      parameters:
        - $ref: '#/components/parameters/usernamePath'
        - $ref: '#/components/parameters/offeringIdPath'
      responses:
        '402':
          description: Payment required
          headers:
            PAYMENT-REQUIRED:
              schema:
                type: string
              description: Base64 x402 v2 payment required
            X-Vybe-Public-Id:
              schema:
                type: string
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '200':
          description: Paid via facilitator
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags: [Collect]
      summary: Same as GET (POST offering URL)
      operationId: agentCollectOfferingPost
      parameters:
        - $ref: '#/components/parameters/usernamePath'
        - $ref: '#/components/parameters/offeringIdPath'
      responses:
        '402':
          description: Payment required
        '200':
          description: Paid
        '404':
          $ref: '#/components/responses/NotFound'

  /api/agent/collect/{username}/{offeringId}/status:
    get:
      tags: [Collect]
      summary: Poll agent payment status
      operationId: getAgentCollectStatus
      parameters:
        - $ref: '#/components/parameters/usernamePath'
        - $ref: '#/components/parameters/offeringIdPath'
        - name: publicId
          in: query
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentCollectStatus'
        '404':
          $ref: '#/components/responses/NotFound'

    post:
      tags: [Collect]
      summary: Settle agent intent with txHash
      operationId: postAgentCollectStatus
      parameters:
        - $ref: '#/components/parameters/usernamePath'
        - $ref: '#/components/parameters/offeringIdPath'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [publicId, txHash]
              properties:
                publicId:
                  type: string
                txHash:
                  type: string
                  pattern: '^0x[0-9a-fA-F]{64}$'
                chain:
                  $ref: '#/components/schemas/EvmChain'
                token:
                  $ref: '#/components/schemas/StableToken'
                payerNote:
                  type: string
                  maxLength: 120
      responses:
        '200':
          description: Verified / paid
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/AgentCollectStatus'
                  - type: object
                    properties:
                      verified:
                        type: boolean
                      alreadyPaid:
                        type: boolean
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  /api/agent/pay:
    get:
      tags: [AgentSpend]
      summary: List my agent spends (pending approvals / paid)
      description: |
        VAPT owner only. Use `status=pending` for the approve queue.
        For the full Agentic page feed (spend + collect, pagination, summary),
        prefer `GET /api/agent/payments`.
      operationId: listAgentPays
      parameters:
        - $ref: '#/components/parameters/crossmintIdQueryRequired'
        - name: status
          in: query
          schema:
            type: string
            enum: [all, pending, paid]
            default: all
      responses:
        '200':
          description: Pays
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentPaysResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
    post:
      tags: [AgentSpend]
      summary: Agent starts Collect pay under VAPT (not called by mobile UI)
      description: |
        `Authorization: Bearer vapt_…` (not Crossmint JWT).
        Returns `awaiting_approval` + `approveUrl`. Mobile eng should **not**
        mint VAPTs from the daily loop — issue on web Settings; mobile
        consumes pending approvals via GET + notifications.
      operationId: postAgentPay
      security:
        - VaptBearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                username:
                  type: string
                offeringId:
                  type: string
                offeringUrl:
                  type: string
                  format: uri
      responses:
        '201':
          description: Awaiting user approval
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentPayResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Policy denial
        '404':
          $ref: '#/components/responses/NotFound'

  /api/agent/payments:
    get:
      tags: [AgentSpend]
      summary: Agentic page feed — all agent payments for me
      description: |
        Signed-in user feed for the mobile **Agentic** screen.
        - `role=spend` — pays your VAPT started (approve queue + history)
        - `role=collect` — agents that paid your Collect offerings
        - `role=all` — both (each row has `direction`)
        Includes `summary` counts for badges/tabs.
      operationId: listAgentPaymentsFeed
      parameters:
        - $ref: '#/components/parameters/crossmintIdQueryRequired'
        - name: role
          in: query
          schema:
            type: string
            enum: [all, spend, collect]
            default: all
        - name: status
          in: query
          schema:
            type: string
            enum: [all, pending, paid, cancelled, expired]
            default: all
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: pageSize
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 30
      responses:
        '200':
          description: Paginated agent payments + summary
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentPaymentsFeedResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'

  /api/vapt/credentials:
    get:
      tags: [AgentSpend]
      summary: List my VAPTs (no secrets)
      description: |
        Prefer web Settings for mint/revoke; mobile may list for status.
        Default returns **active** only. Pass `includeRevoked=true` to also return
        blocked keys (`status: revoked`, `revokedAt`).
      operationId: listVaptCredentials
      parameters:
        - $ref: '#/components/parameters/crossmintIdQueryRequired'
        - name: includeRevoked
          in: query
          required: false
          schema:
            type: boolean
            default: false
          description: When true, include revoked/blocked VAPTs in the list.
      responses:
        '200':
          description: Credentials
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VaptCredentialsResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      tags: [AgentSpend]
      summary: Issue VAPT (plaintext once)
      description: Desk / Settings primary. Limits required (no silent unlimited).
      operationId: createVaptCredential
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateVaptBody'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreateVaptResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
    delete:
      tags: [AgentSpend]
      summary: Revoke VAPT
      operationId: revokeVaptCredential
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [crossmintId, credentialId]
              properties:
                crossmintId:
                  type: string
                credentialId:
                  type: string
      responses:
        '200':
          description: Revoked
        '404':
          $ref: '#/components/responses/NotFound'

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Crossmint Auth JWT. Native v1 must send this on owner routes.
        `sub` must match `crossmintId`. Collect keys (`vybe_col_…`) are a different scheme.
    CrossmintId:
      type: apiKey
      in: query
      name: crossmintId
      description: |
        Crossmint user id from Auth SDK (`sub`). For POST/PATCH/PUT/DELETE, send the
        same field in the JSON body. Must match the Bearer JWT when a JWT is present.
    CollectApiKey:
      type: http
      scheme: bearer
      bearerFormat: vybe_col
      description: Collect business API key (`vybe_col_…`) — desk, not mobile v1
    VaptBearer:
      type: http
      scheme: bearer
      bearerFormat: vapt
      description: Agent spend token (`vapt_…`) — for `POST /api/agent/pay` only

  parameters:
    crossmintIdQuery:
      name: crossmintId
      in: query
      required: false
      schema:
        type: string
        example: usr_abc123
    crossmintIdQueryRequired:
      name: crossmintId
      in: query
      required: true
      schema:
        type: string
        example: usr_abc123
    usernamePath:
      name: username
      in: path
      required: true
      schema:
        type: string
        example: alex
    offeringIdPath:
      name: offeringId
      in: path
      required: true
      schema:
        type: string
        example: svc_9fQ2Kd

  responses:
    BadRequest:
      description: Validation failed
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorMessage'
    Unauthorized:
      description: Auth required / invalid VAPT or session
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorMessage'
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorMessage'
    Conflict:
      description: Conflict
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorMessage'
    Forbidden:
      description: Forbidden
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorMessage'
    ServerError:
      description: Server error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorMessage'

  schemas:
    ErrorMessage:
      type: object
      required: [message]
      properties:
        message:
          type: string
        code:
          type: string

    StableToken:
      type: string
      enum: [usdc, usdt]

    EvmChain:
      type: string
      enum: [base, ethereum, bsc]
      description: EVM networks for deposits / settle verify

    AppChain:
      type: string
      enum: [base, solana, ethereum, bsc]
      description: Chains available in Crossmint wallet switcher

    VybePlan:
      type: string
      enum: [free, business]

    FeePayer:
      type: string
      enum: [creator, client]

    PaymentRequestStatus:
      type: string
      enum: [pending, paid, expired, cancelled]

    PaymentRequestSource:
      type: string
      enum: [manual, service, agent_collect, friend]

    RecurringInterval:
      type: string
      enum: [weekly, monthly]

    ServiceAccent:
      type: string
      enum: [ember, forest, ocean, sand]

    AvatarId:
      type: string
      enum: [leaf, sun, wave, bolt, fire, moon, star, seed, glow, coin, palm, fox]

    NotificationType:
      type: string
      enum:
        - payment_received
        - friend_added
        - payment_request
        - agent_spend_approval
        - system
      description: |
        `agent_spend_approval` — agent started a Collect pay under your VAPT.
        Open `requestPublicId` via `/r/{id}` (or `approveUrl` from `GET /api/agent/pay`).

    InterestType:
      type: string
      enum: [onramp, offramp, card, developer, agent_collect]

    WalletAddress:
      type: string
      pattern: '^0x[a-fA-F0-9]{40}$'
      example: '0x8f2c00000000000000000000000000000000c21a'

    Username:
      type: string
      minLength: 3
      maxLength: 20
      pattern: '^[a-z0-9_]{3,20}$'
      example: alex

    MyProfileResponse:
      type: object
      properties:
        username:
          $ref: '#/components/schemas/Username'
        avatar:
          $ref: '#/components/schemas/AvatarId'
        walletAddress:
          type: string
          nullable: true
          pattern: '^0x[a-fA-F0-9]{40}$'
          example: '0x8f2c00000000000000000000000000000000c21a'
        plan:
          $ref: '#/components/schemas/VybePlan'
        planExpiresAt:
          type: string
          format: date-time
          nullable: true
        displayName:
          type: string
          nullable: true
        tagline:
          type: string
          nullable: true
        firstName:
          type: string
          nullable: true
        lastName:
          type: string
          nullable: true
        premiumUsernamePaid:
          type: boolean
        approvalPinSet:
          type: boolean
          description: True when a 6-digit payment PIN exists (never returns the PIN)

    ApprovalPinStatus:
      type: object
      properties:
        pinSet:
          type: boolean
        pinSetAt:
          type: string
          format: date-time
          nullable: true
        locked:
          type: boolean
        lockedUntil:
          type: string
          format: date-time
          nullable: true
        attemptsRemaining:
          type: integer

    ApprovalPinActionBody:
      type: object
      required: [action, crossmintId]
      properties:
        action:
          type: string
          enum: [set, change, verify]
        crossmintId:
          type: string
        pin:
          type: string
          pattern: '^[0-9]{6}$'
          description: Required for set and verify
        currentPin:
          type: string
          pattern: '^[0-9]{6}$'
          description: Required for change
        newPin:
          type: string
          pattern: '^[0-9]{6}$'
          description: Required for change

    PublicProfileResponse:
      type: object
      properties:
        username:
          $ref: '#/components/schemas/Username'
        avatar:
          $ref: '#/components/schemas/AvatarId'
        walletAddress:
          type: string
          nullable: true
        crossmintId:
          type: string
        firstName:
          type: string
          nullable: true
        lastName:
          type: string
          nullable: true
        displayName:
          type: string
          nullable: true
        tagline:
          type: string
          nullable: true

    UsernameCheckResponse:
      type: object
      properties:
        available:
          type: boolean
        message:
          type: string
        premium:
          type: boolean
        premiumPriceUsd:
          type: number
        treasuryAddress:
          type: string
          nullable: true

    ProfileSearchResponse:
      type: object
      properties:
        results:
          type: array
          items:
            type: object
            properties:
              username:
                type: string
              avatar:
                type: string
              walletAddress:
                type: string
                nullable: true
              crossmintId:
                type: string
              firstName:
                type: string
                nullable: true
              lastName:
                type: string
                nullable: true

    ClaimProfileRequest:
      type: object
      required: [crossmintId, username, firstName, lastName]
      properties:
        crossmintId:
          type: string
        email:
          type: string
          format: email
        username:
          $ref: '#/components/schemas/Username'
        firstName:
          type: string
          minLength: 1
          maxLength: 40
        lastName:
          type: string
          minLength: 1
          maxLength: 40
        avatar:
          $ref: '#/components/schemas/AvatarId'
        walletAddress:
          $ref: '#/components/schemas/WalletAddress'
        premiumPaidConfirmed:
          type: boolean

    ClaimProfileResponse:
      type: object
      properties:
        username:
          type: string
        firstName:
          type: string
        lastName:
          type: string
        avatar:
          type: string
        walletAddress:
          type: string
          nullable: true
        premiumUsernamePaid:
          type: boolean
        plan:
          $ref: '#/components/schemas/VybePlan'

    PatchProfileRequest:
      type: object
      required: [crossmintId]
      properties:
        crossmintId:
          type: string
        avatar:
          $ref: '#/components/schemas/AvatarId'
        walletAddress:
          $ref: '#/components/schemas/WalletAddress'
        username:
          $ref: '#/components/schemas/Username'
        email:
          type: string
          format: email
        firstName:
          type: string
        lastName:
          type: string
        displayName:
          type: string
          maxLength: 60
        tagline:
          type: string
          maxLength: 120
        premiumPaidConfirmed:
          type: boolean

    PatchProfileResponse:
      type: object
      properties:
        username:
          type: string
        avatar:
          type: string
        walletAddress:
          type: string
          nullable: true
        plan:
          $ref: '#/components/schemas/VybePlan'
        firstName:
          type: string
          nullable: true
        lastName:
          type: string
          nullable: true
        displayName:
          type: string
          nullable: true
        tagline:
          type: string
          nullable: true

    PremiumUsernameRequired:
      type: object
      properties:
        message:
          type: string
        code:
          type: string
          example: PREMIUM_USERNAME
        premiumPriceUsd:
          type: number
        treasuryAddress:
          type: string

    BillingStatus:
      type: object
      properties:
        plan:
          $ref: '#/components/schemas/VybePlan'
        planExpiresAt:
          type: string
          format: date-time
          nullable: true
        businessPriceUsd:
          type: number
        businessFree:
          type: boolean
        premiumUsernamePriceUsd:
          type: number
        treasuryAddress:
          type: string
          nullable: true
        billingEnabled:
          type: boolean
        displayName:
          type: string
        tagline:
          type: string

    Friend:
      type: object
      properties:
        crossmintId:
          type: string
        username:
          type: string
        firstName:
          type: string
          nullable: true
        lastName:
          type: string
          nullable: true
        avatar:
          type: string
        walletAddress:
          type: string
          nullable: true
        addedAt:
          type: string
          format: date-time

    FriendsListResponse:
      type: object
      properties:
        friends:
          type: array
          items:
            $ref: '#/components/schemas/Friend'
        total:
          type: integer
        page:
          type: integer
        pageSize:
          type: integer
        totalPages:
          type: integer

    FeeQuoteResponse:
      type: object
      description: Returned when calling GET with quoteAmount
      additionalProperties: true
      properties:
        amount:
          type: string
        feeBps:
          type: number
        feeAmount:
          type: string
        netAmount:
          type: string
        payAmount:
          type: string
        feePayer:
          $ref: '#/components/schemas/FeePayer'
        feeWaived:
          type: boolean
        feesLive:
          type: boolean
        treasuryAddress:
          type: string
          nullable: true

    PaymentRequest:
      type: object
      description: Serialized payment request used across list/create/patch
      properties:
        publicId:
          type: string
          example: Vh3xR2mQ
        creatorCrossmintId:
          type: string
        creatorUsername:
          type: string
        creatorAvatar:
          type: string
          nullable: true
        displayName:
          type: string
          nullable: true
        tagline:
          type: string
          nullable: true
        walletAddress:
          $ref: '#/components/schemas/WalletAddress'
        amount:
          type: string
          description: Face / invoice amount
        invoiceAmount:
          type: string
        feeAmount:
          type: string
        netAmount:
          type: string
          description: Amount creator receives when fees live
        payAmount:
          type: string
          description: Amount payer must send (may include fee if feePayer=client)
        feeBps:
          type: number
        feePayer:
          $ref: '#/components/schemas/FeePayer'
        feeWaived:
          type: boolean
        treasuryAddress:
          type: string
          nullable: true
        token:
          $ref: '#/components/schemas/StableToken'
        memo:
          type: string
          nullable: true
        status:
          $ref: '#/components/schemas/PaymentRequestStatus'
        expiresAt:
          type: string
          format: date-time
          nullable: true
        paidAt:
          type: string
          format: date-time
          nullable: true
        payerUsername:
          type: string
          nullable: true
        createdAt:
          type: string
          format: date-time
        seriesId:
          type: string
          nullable: true
        recurringInterval:
          type: string
          nullable: true
          enum: [weekly, monthly]
        seriesIndex:
          type: integer
          nullable: true
        source:
          $ref: '#/components/schemas/PaymentRequestSource'
        offeringId:
          type: string
          nullable: true
        settlementTxHash:
          type: string
          nullable: true
        payerNote:
          type: string
          nullable: true
        requestUrl:
          type: string
          description: Absolute or relative /r/{publicId} when provided by client helpers

    CreatePaymentRequestBody:
      type: object
      required: [crossmintId, amount]
      properties:
        crossmintId:
          type: string
        amount:
          type: string
          pattern: '^\d+(\.\d{1,6})?$'
          example: '150.00'
        token:
          type: string
          enum: [usdc, usdt]
          default: usdc
        memo:
          type: string
          maxLength: 140
        expiresInHours:
          type: integer
          minimum: 1
          maximum: 720
        feePayer:
          type: string
          enum: [creator, client]
          default: creator
        recurringInterval:
          $ref: '#/components/schemas/RecurringInterval'
        targetUsername:
          type: string
          description: Deliver to this friend (must be on friends list)

    CancelPaymentRequestBody:
      type: object
      required: [action, publicId, crossmintId]
      properties:
        action:
          type: string
          enum: [cancel]
        publicId:
          type: string
        crossmintId:
          type: string

    MarkPaidPaymentRequestBody:
      type: object
      required: [publicId, payerCrossmintId]
      properties:
        publicId:
          type: string
        payerCrossmintId:
          type: string
        payerUsername:
          type: string
        feeCollectedOnChain:
          type: boolean
        approvalPinToken:
          type: string
          description: |
            Required when the payer has a payment PIN. From
            POST /api/security/approval-pin (set or verify). ~90s TTL.

    SettlePaymentRequestBody:
      type: object
      required: [publicId, txHash]
      properties:
        publicId:
          type: string
        txHash:
          type: string
          pattern: '^0x[0-9a-fA-F]{64}$'
        chain:
          $ref: '#/components/schemas/EvmChain'
        payerNote:
          type: string
          maxLength: 120

    ServiceOffering:
      type: object
      properties:
        id:
          type: string
        title:
          type: string
        description:
          type: string
        price:
          type: string
        token:
          $ref: '#/components/schemas/StableToken'
        active:
          type: boolean
        sortOrder:
          type: integer

    ServicePage:
      type: object
      properties:
        username:
          type: string
        headline:
          type: string
        bio:
          type: string
        accent:
          $ref: '#/components/schemas/ServiceAccent'
        published:
          type: boolean
        agentCollectEnabled:
          type: boolean
        ctaLabel:
          type: string
        offerings:
          type: array
          items:
            $ref: '#/components/schemas/ServiceOffering'
        avatar:
          type: string
          nullable: true
        displayName:
          type: string
          nullable: true
        tagline:
          type: string
          nullable: true
        hasWallet:
          type: boolean
        walletAddress:
          type: string
          nullable: true
        webhookUrl:
          type: string
          nullable: true
        webhookConfigured:
          type: boolean

    UpsertServicePageBody:
      type: object
      required: [crossmintId]
      properties:
        crossmintId:
          type: string
        headline:
          type: string
          maxLength: 80
        bio:
          type: string
          maxLength: 400
        accent:
          $ref: '#/components/schemas/ServiceAccent'
        published:
          type: boolean
        agentCollectEnabled:
          type: boolean
        ctaLabel:
          type: string
          maxLength: 40
        offerings:
          type: array
          maxItems: 20
          items:
            type: object
            required: [title, price]
            properties:
              id:
                type: string
              title:
                type: string
                maxLength: 80
              description:
                type: string
                maxLength: 280
              price:
                type: string
              token:
                $ref: '#/components/schemas/StableToken'
              active:
                type: boolean
              sortOrder:
                type: integer

    NotificationsResponse:
      type: object
      properties:
        unread:
          type: integer
        notifications:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
              type:
                $ref: '#/components/schemas/NotificationType'
              title:
                type: string
              body:
                type: string
              amount:
                type: string
                nullable: true
              actorUsername:
                type: string
                nullable: true
              requestPublicId:
                type: string
                nullable: true
                description: For payment_request / agent_spend_approval → deep link `/r/{id}`
              read:
                type: boolean
              createdAt:
                type: string
                format: date-time

    AgentPaysResponse:
      type: object
      properties:
        pays:
          type: array
          items:
            type: object
            properties:
              publicId:
                type: string
              status:
                type: string
                enum: [pending, paid, expired, cancelled]
              amount:
                type: string
              token:
                $ref: '#/components/schemas/StableToken'
              memo:
                type: string
                nullable: true
              sellerUsername:
                type: string
              offeringId:
                type: string
                nullable: true
              vaptCredentialId:
                type: string
                nullable: true
              approveUrl:
                type: string
                format: uri
              paidAt:
                type: string
                format: date-time
                nullable: true
              createdAt:
                type: string
                format: date-time

    AgentPaymentsFeedResponse:
      type: object
      properties:
        protocol:
          type: string
          example: vybe-agent-payments
        version:
          type: string
        role:
          type: string
          enum: [all, spend, collect]
        status:
          type: string
        page:
          type: integer
        pageSize:
          type: integer
        total:
          type: integer
        summary:
          type: object
          properties:
            pendingApprovals:
              type: integer
            spentPaid:
              type: integer
            spentDeclined:
              type: integer
            collectedPaid:
              type: integer
        payments:
          type: array
          items:
            type: object
            properties:
              publicId:
                type: string
              direction:
                type: string
                enum: [spend, collect]
              status:
                type: string
                enum: [pending, paid, expired, cancelled]
              title:
                type: string
              amount:
                type: string
              token:
                $ref: '#/components/schemas/StableToken'
              memo:
                type: string
                nullable: true
              counterpartyUsername:
                type: string
                nullable: true
              sellerUsername:
                type: string
              offeringId:
                type: string
                nullable: true
              vaptCredentialId:
                type: string
                nullable: true
              approveUrl:
                type: string
                format: uri
                nullable: true
              paidAt:
                type: string
                format: date-time
                nullable: true
              createdAt:
                type: string
                format: date-time

    AgentPayResponse:
      type: object
      properties:
        protocol:
          type: string
          example: vybe-agent-pay
        status:
          type: string
          example: awaiting_approval
        publicId:
          type: string
        amount:
          type: string
        token:
          $ref: '#/components/schemas/StableToken'
        approveUrl:
          type: string
          format: uri
        statusUrl:
          type: string
          format: uri
        message:
          type: string

    VaptCredentialsResponse:
      type: object
      properties:
        credentials:
          type: array
          items:
            $ref: '#/components/schemas/VaptCredential'
        summary:
          type: object
          properties:
            active:
              type: integer
            revoked:
              type: integer
              description: Present when includeRevoked=true
            includeRevoked:
              type: boolean

    VaptCredential:
      type: object
      properties:
        id:
          type: string
        keyPrefix:
          type: string
        label:
          type: string
        username:
          type: string
        perTxMaxUsd:
          type: number
        dailyCapUsd:
          type: number
        allowedSellerUsernames:
          type: array
          items:
            type: string
        expiresAt:
          type: string
          format: date-time
          nullable: true
        lastUsedAt:
          type: string
          format: date-time
          nullable: true
        revokedAt:
          type: string
          format: date-time
          nullable: true
        status:
          type: string
          enum: [active, revoked]
        createdAt:
          type: string
          format: date-time

    CreateVaptBody:
      type: object
      required: [crossmintId, perTxMaxUsd, dailyCapUsd]
      properties:
        crossmintId:
          type: string
        label:
          type: string
        perTxMaxUsd:
          type: number
        dailyCapUsd:
          type: number
        allowedSellerUsernames:
          type: array
          items:
            type: string
        expiresAt:
          type: string
          format: date-time
          nullable: true

    CreateVaptResponse:
      type: object
      properties:
        credential:
          $ref: '#/components/schemas/VaptCredential'
        vapt:
          type: string
          description: Plaintext once — `vapt_…`
        warning:
          type: string

    CreateNotificationBody:
      type: object
      required: [recipientCrossmintId, type, title, body]
      properties:
        recipientCrossmintId:
          type: string
        actorCrossmintId:
          type: string
        actorUsername:
          type: string
        type:
          $ref: '#/components/schemas/NotificationType'
        title:
          type: string
          maxLength: 120
        body:
          type: string
          maxLength: 280
        amount:
          type: string
        requestPublicId:
          type: string

    FiatStatusResponse:
      type: object
      description: Shape may grow; treat unknown fields as forward-compatible
      properties:
        configured:
          type: boolean
        mode:
          type: string
          enum: [live, sandbox]
        kyc:
          type: object
          additionalProperties: true
        virtualAccount:
          type: object
          nullable: true
          additionalProperties: true
        bankAccounts:
          type: array
          items:
            type: object
            additionalProperties: true
        transfers:
          type: array
          items:
            type: object
            additionalProperties: true
        notice:
          type: string
          nullable: true

    AgentCollectCatalog:
      type: object
      properties:
        protocol:
          type: string
          example: vybe-agent-collect
        version:
          type: string
          example: '0.1'
        username:
          type: string
        displayName:
          type: string
          nullable: true
        headline:
          type: string
        bio:
          type: string
        humanPage:
          type: string
        docsPage:
          type: string
        catalogUrl:
          type: string
        currencyNote:
          type: string
        offerings:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
              title:
                type: string
              description:
                type: string
              price:
                type: string
              token:
                type: string
              payUrl:
                type: string

    AgentCollectStatus:
      type: object
      properties:
        protocol:
          type: string
        version:
          type: string
        publicId:
          type: string
        status:
          $ref: '#/components/schemas/PaymentRequestStatus'
        amount:
          type: string
        token:
          type: string
        memo:
          type: string
          nullable: true
        paidAt:
          type: string
          format: date-time
          nullable: true
        expiresAt:
          type: string
          format: date-time
          nullable: true
        offeringId:
          type: string
          nullable: true
        username:
          type: string
        settlementTxHash:
          type: string
          nullable: true
