Totalis User API

User profile, wallet, and devnet helpers.

OpenAPI Specification

totalis-user-api-openapi.yml Raw ↑
openapi: 3.0.3
info:
  title: Totalis RFQ API Keys User API
  version: 2.1.0
  description: 'Public REST surface for the Totalis parlay RFQ platform — a decentralized request-for-quote marketplace for parlay bets across Kalshi and Polymarket, with on-chain Solana vault settlement.


    **Wire format**: snake_case JSON. All successful responses are wrapped in `{ "data": ... }`; list endpoints add `meta` with cursor-based pagination. Errors use a `{ "error": { code, message, details? } }` envelope.


    **Authentication**: programmatic clients send `X-API-Key`; the web dashboard uses Privy JWTs (`Authorization: Bearer ...`). Any authenticated user can both place parlays and quote as a market maker — there is no separate MM role. Admin endpoints are out of scope for this public reference.


    **Rate limiting**: 100 req/min anonymous, 300 req/min authenticated. Responses include `X-RateLimit-*` headers. Request body limit: 256KB.


    **Pagination**: list endpoints use opaque cursor pagination. Pass `meta.cursor` from the previous response as `?cursor=...` to fetch the next page; do not parse or construct cursors client-side.'
servers:
- url: https://api.totalis.trade
  description: Production
security:
- ApiKey: []
tags:
- name: User
  description: User profile, wallet, and devnet helpers.
paths:
  /v1/me:
    get:
      operationId: getOrCreateUser
      summary: Get Profile
      description: 'Get the authenticated user''s profile. Auto-creates a user record on first call. Pass `?ref=<invite-code>` on first login to auto-redeem an invite code; when redemption succeeds the response includes `auto_redeemed: true` and, where configured, an `airdrop` outcome.'
      tags:
      - User
      security:
      - PrivyJWT: []
      - ApiKey: []
      parameters:
      - name: ref
        in: query
        description: Referral / invite code to auto-redeem on first login.
        schema:
          type: string
      responses:
        '200':
          description: User profile
          content:
            application/json:
              schema:
                type: object
                required:
                - data
                properties:
                  data:
                    $ref: '#/components/schemas/User'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /v1/username:
    put:
      operationId: updateUsername
      summary: Update Username
      description: Change the authenticated user's display name. Must be 3–20 characters, alphanumeric and underscores only.
      tags:
      - User
      security:
      - PrivyJWT: []
      - ApiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - username
              properties:
                username:
                  type: string
                  minLength: 3
                  maxLength: 20
                  pattern: ^[a-zA-Z0-9_]+$
                  description: New username (3–20 chars, alphanumeric + underscores).
            example:
              username: trader_123
      responses:
        '200':
          description: Username updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      username:
                        type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          $ref: '#/components/responses/Conflict'
  /v1/username/check:
    get:
      operationId: checkUsername
      summary: Check Username
      description: Check whether a username is available. Must match 3–20 chars, alphanumeric + underscores.
      tags:
      - User
      security:
      - PrivyJWT: []
      - ApiKey: []
      parameters:
      - name: username
        in: query
        required: true
        description: Candidate username.
        schema:
          type: string
      responses:
        '200':
          description: Availability result
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      available:
                        type: boolean
        '401':
          $ref: '#/components/responses/Unauthorized'
  /v1/wallet:
    get:
      operationId: getWallet
      summary: Get Wallet
      description: Get the authenticated user's embedded Solana wallet address, wallet/vault balances, and the aggregated `locked_amount` across active RFQs and vault positions.
      tags:
      - User
      security:
      - PrivyJWT: []
      - ApiKey: []
      responses:
        '200':
          description: Wallet snapshot
          content:
            application/json:
              schema:
                type: object
                required:
                - data
                properties:
                  data:
                    $ref: '#/components/schemas/Wallet'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /v1/wallet/blockhash:
    get:
      operationId: getBlockhash
      summary: Get Blockhash
      description: Latest Solana blockhash for client-side transaction construction. Useful for browser clients that do not have direct RPC access.
      tags:
      - User
      security:
      - PrivyJWT: []
      - ApiKey: []
      responses:
        '200':
          description: Latest blockhash
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required:
                    - blockhash
                    - last_valid_block_height
                    properties:
                      blockhash:
                        type: string
                      last_valid_block_height:
                        type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
  /v1/wallet/submit-tx:
    post:
      operationId: submitTransaction
      summary: Submit Transaction
      description: Submit a client-signed serialized Solana transaction. Used for wallet delegation and other user-signed flows from browser clients without direct RPC access. Rate-limited to 10 requests/minute.
      tags:
      - User
      security:
      - PrivyJWT: []
      - ApiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - transaction
              properties:
                transaction:
                  type: string
                  description: Base64-encoded serialized Solana transaction.
            example:
              transaction: AQAAAA...
      responses:
        '200':
          description: Transaction broadcast
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required:
                    - signature
                    properties:
                      signature:
                        type: string
                        description: Solana transaction signature.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/portfolio:
    get:
      operationId: getUserPortfolio
      tags:
      - User
      summary: Consolidated portfolio (balance, stats, counts, summary)
      description: 'Returns the user''s full portfolio in a single batched query: vault balance,

        trading stats, RFQ status counts, and active-bet summary. Balance is null

        when the user has no vault (never traded).

        '
      security:
      - PrivyJWT: []
      - ApiKey: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PortfolioData'
  /v1/wallet/delegation-status:
    get:
      operationId: getDelegationStatus
      tags:
      - User
      summary: Check TEE wallet delegation status
      security:
      - PrivyJWT: []
      - ApiKey: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/DelegationStatus'
  /v1/wallet/enable-trading:
    post:
      operationId: enableTrading
      tags:
      - User
      summary: Confirm TEE wallet delegation AND drain any pending welcome airdrop
      description: "Two responsibilities, kept in one round-trip so the frontend can\nrender a single combined success toast:\n\n1. **Delegation status.** Confirms whether the embedded wallet\n   is currently delegated to our key quorum. The actual\n   delegation is a USER-SIDE action (frontend `addSigners()`);\n   this endpoint only reads the current state — it does NOT\n   perform the delegation itself.\n2. **Pending welcome-airdrop drain.** Under the deferred-airdrop\n   flow (TT-airdrop-bug 2026-05-13), the welcome airdrop amount\n   is staged on `users.welcome_airdrop_pending_amount` at\n   approval time. This endpoint atomically claims that amount,\n   mints the USDC from treasury to the canonical wallet, and\n   clears the column. Idempotent: a second call after a\n   successful drain returns `airdrop: null` (nothing left to\n   drain) without re-minting. On mint failure, the pending\n   amount is restored so the user can retry on their next call.\n"
      security:
      - PrivyJWT: []
      - ApiKey: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                    - $ref: '#/components/schemas/DelegationStatus'
                    - type: object
                      properties:
                        airdrop:
                          allOf:
                          - $ref: '#/components/schemas/AirdropDrainOutcome'
                          nullable: true
                          description: 'Outcome of the pending-airdrop drain.

                            `null` when nothing was staged for this

                            user (most common — pre-approval, or

                            already drained on a prior call). See

                            `AirdropDrainOutcome` for the non-null

                            shape.

                            '
        '401':
          $ref: '#/components/responses/Unauthorized'
  /v1/wallet/withdraw:
    post:
      operationId: withdrawFromWallet
      tags:
      - User
      summary: Withdraw USDC from the embedded wallet to an external address
      description: 'Sends USDC out of the caller''s embedded wallet ATA to an arbitrary

        on-curve Solana address. Uses Privy''s session-signer delegation

        (set up via /v1/wallet/enable-trading) and Privy''s native gas

        sponsorship — the user is not prompted to sign and never needs SOL.


        Chained vault-tap: when the requested `amount_usdc` exceeds the

        caller''s ATA-available balance, the handler first issues

        `withdraw_from_vault` (vault PDA → owner ATA) for the shortfall,

        waits for that TX to confirm, then issues the external SPL transfer.

        The `vault_signature` / `vault_amount` fields surface the intermediate

        TX when this happens. If the vault tap succeeds but the external

        transfer fails to construct or broadcast, the response is **502**

        with `error.code: PARTIAL_VAULT_ONLY` and the vault TX surfaced under

        `error.details.vault_signature` / `error.details.vault_amount`. The

        caller can retry with the same amount; the pre-flight will see the

        ATA is now sufficient and skip the vault tap. 502 (rather than 200)

        is deliberate: the idempotency middleware caches only 2xx, so

        same-key retries against a 502 re-enter the handler instead of

        replaying a cached partial response.


        Idempotency-Key is honored: a duplicate request with the same key

        replays the cached 2xx response without re-issuing on-chain TXs.

        '
      security:
      - PrivyJWT: []
      - ApiKey: []
      parameters:
      - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - recipient
              - amount_usdc
              properties:
                recipient:
                  type: string
                  description: 'Destination Solana wallet address (base58). Must be

                    on-curve — PDAs, program IDs, and token-account

                    addresses are rejected because funds sent to a

                    derived ATA-of-an-ATA are unrecoverable.

                    '
                amount_usdc:
                  type: number
                  minimum: 1.0e-06
                  description: 'USDC amount to send. Minimum is one USDC base unit

                    (1e-6). Must be ≤ the caller''s total spendable pool

                    (ATA + vault free_balance − in-flight RFQ holds);

                    requests exceeding this are rejected with 400.

                    '
      responses:
        '200':
          description: 'Withdrawal submitted. `signature` is the external SPL transfer

            signature. `vault_signature` and `vault_amount` are present

            only when the request required a vault → ATA hop.

            '
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      signature:
                        type: string
                        description: External SPL transfer signature.
                      explorer_url:
                        type: string
                        description: Solscan URL for the external SPL signature.
                      vault_signature:
                        type: string
                        description: 'On-chain `withdraw_from_vault` signature.

                          Present only when the request required a vault

                          → ATA hop to cover a shortfall.

                          '
                      vault_amount:
                        type: number
                        description: 'USDC amount moved from vault to ATA before the

                          external transfer. Present only when

                          `vault_signature` is present.

                          '
                      recipient_ata_created:
                        type: boolean
                        description: 'Present (true) when the recipient had no USDC token

                          account and this transaction created one (the

                          create-and-chargeback path, gated by

                          WITHDRAW_ATA_AUTOCREATE). The fee-payer fronts the

                          ~0.002 SOL account rent and bills it back in USDC, so

                          the recipient receives `delivered_usdc` and the treasury

                          keeps `chargeback_usdc`. The create is non-idempotent, so

                          a confirmed response means this tx created the account

                          (a race where it already existed reverts and retries

                          via the standard path, with no chargeback).

                          '
                      chargeback_usdc:
                        type: number
                        description: 'USDC withheld from the requested amount to reimburse

                          the one-time recipient-ATA rent. Present only with

                          `recipient_ata_created`.

                          '
                      delivered_usdc:
                        type: number
                        description: 'USDC actually delivered to the recipient

                          (`amount_usdc − chargeback_usdc`). Present only with

                          `recipient_ata_created`.

                          '
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: 'The caller''s embedded wallet is no longer delegated for

            server-side signing. Re-enabling trading from the app restores

            the session signer.

            '
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          description: 'Ambiguous partial state — returned as 5xx (not 200) so

            Idempotency-Key callers retry against the handler instead of

            replaying a cached partial response. Two codes:


            `PARTIAL_VAULT_ONLY` — the vault tap succeeded but the external SPL

            transfer failed to construct/broadcast. Funds moved from vault to

            ATA; retry the same request to complete the external leg (the

            pre-flight sees the updated ATA and skips the vault tap). Returned

            only when the vault tap fired this request; a same-key retry against

            an already-topped-up ATA skips the tap (`shortfall = 0`) and a

            subsequent SPL failure surfaces as a normal 500.


            `WITHDRAW_SUBMITTED_UNCONFIRMED` — only on the recipient-ATA

            create-and-chargeback path (WITHDRAW_ATA_AUTOCREATE), where broadcast

            and confirmation are separate steps: the transaction was broadcast

            (`details.signature` present) but confirmation timed out, so it may

            have landed. Do NOT blind-retry — verify the recipient balance first,

            since a retry would re-send via the now-existing ATA and double-pay.

            '
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        enum:
                        - PARTIAL_VAULT_ONLY
                        - WITHDRAW_SUBMITTED_UNCONFIRMED
                      message:
                        type: string
                      details:
                        type: object
                        properties:
                          signature:
                            type: string
                            description: 'The broadcast transaction signature. Present on

                              WITHDRAW_SUBMITTED_UNCONFIRMED — the tx may have

                              landed; check it before retrying.

                              '
                          vault_signature:
                            type: string
                          vault_amount:
                            type: number
                          chargeback_usdc:
                            type: number
                            description: 'On WITHDRAW_SUBMITTED_UNCONFIRMED, the USDC that

                              would have been withheld for the recipient-ATA rent.

                              '
                          delivered_usdc:
                            type: number
                            description: 'On WITHDRAW_SUBMITTED_UNCONFIRMED, the USDC that

                              would have been delivered to the recipient.

                              '
                          partial:
                            type: string
                            enum:
                            - vault_only
                            - submitted_unconfirmed
components:
  schemas:
    PortfolioSummary:
      type: object
      description: Derived portfolio-level aggregates from active positions.
      properties:
        active_count:
          type: integer
          description: Number of active (non-terminal) RFQs
        active_bet_total:
          type: number
          description: Sum of bet_amount across active RFQs
        portfolio_value:
          type: number
          description: Gross vault balance (deposited USDC). Does not include unrealized P&L.
    User:
      type: object
      properties:
        id:
          type: string
          description: Privy DID — internal identifier, not exposed on public endpoints.
        username:
          type: string
          description: Display name. Auto-set to the wallet address on first login; can be updated via `PUT /v1/username` to a 3-20 char alphanumeric.
        email:
          type: string
          nullable: true
        wallet_address:
          type: string
          nullable: true
        approved:
          type: boolean
          description: True once the user has redeemed a valid invite code or has been admin-approved.
        waitlisted:
          type: boolean
          description: True when `approved=false` and the user is on the waitlist; false otherwise. Always present.
        auto_redeemed:
          type: boolean
          description: Only present when the `?ref` param successfully redeemed an invite code in this exact request.
        airdrop:
          nullable: true
          description: 'Welcome-airdrop outcome from the auto-redeem path. Present alongside `auto_redeemed: true`. `null` when the invite has no airdrop configured or the user was already credited.'
          allOf:
          - $ref: '#/components/schemas/AirdropOutcome'
        created_at:
          type: string
          format: date-time
    ErrorEnvelope:
      type: object
      required:
      - error
      properties:
        error:
          $ref: '#/components/schemas/ErrorBody'
    UserStats:
      type: object
      properties:
        total_parlays:
          type: integer
        wins:
          type: integer
        losses:
          type: integer
        total_wagered:
          type: number
        realized_pnl:
          type: number
        updated_at:
          type: string
          format: date-time
          nullable: true
          description: Null when the user has never settled a trade (no rfq.user_stats row yet).
    PortfolioBalance:
      type: object
      description: 'Caller''s vault balance (DB-cached, no RPC). Null only when the

        caller has no vault row at all. For dual-role accounts the

        bettor-side vault is preferred and the maker-side row is used

        as a fallback (same rules as `GET /v1/vault`).

        '
      properties:
        gross_balance:
          type: number
        locked_collateral:
          type: number
        free_balance:
          type: number
        vault_pda:
          type: string
        vault_token_account:
          type: string
    AirdropOutcome:
      type: object
      description: 'Welcome airdrop outcome. `success: true` with `pending: true` means the amount was staged and will mint on the user''s next `POST /v1/wallet/enable-trading` call. `success: false` means staging failed.'
      required:
      - success
      - amount
      - pending
      properties:
        success:
          type: boolean
        amount:
          type: number
          description: USDC (whole dollars).
        pending:
          type: boolean
    RfqCounts:
      type: object
      properties:
        open:
          type: integer
        quoted:
          type: integer
        accepted:
          type: integer
        confirmed:
          type: integer
        executed:
          type: integer
          description: Positions on-chain but not yet resolved. Distinct from total_settled — settled means market resolution + payout distributed.
        total_settled:
          type: integer
        total_cancelled:
          type: integer
        total_expired:
          type: integer
        total_failed:
          type: integer
          description: Parlays where the vault position errored on-chain. Derived from vault_positions.status = 'error'.
    AirdropDrainOutcome:
      type: object
      description: 'Outcome of the pending-airdrop drain performed by

        `POST /v1/wallet/enable-trading`. Narrower than

        `AirdropOutcome` — there is no `pending` field because the drain

        is the terminal step (either the mint landed or it didn''t; the

        amount is never left in a staged state by this endpoint).

        `{ success: true, amount }` — the staged amount minted

        successfully on-chain. `{ success: false, amount }` — the mint

        attempt failed and the pending amount has been restored; the

        user can retry on their next call.

        '
      required:
      - success
      - amount
      properties:
        success:
          type: boolean
        amount:
          type: number
          description: USDC amount (whole dollars)
    DelegationStatus:
      type: object
      properties:
        ready:
          type: boolean
          description: True if a wallet is linked and delegation can proceed
        delegated:
          type: boolean
          description: True if TEE wallet delegation is active (server can sign)
        address:
          type: string
          description: Solana wallet address (base58). Empty string if no wallet linked.
        wallet_id:
          type: string
          description: Privy embedded wallet ID (used for delegation API calls)
    Wallet:
      type: object
      properties:
        address:
          type: string
          nullable: true
          description: Solana wallet address (base58). Null if no wallet is linked yet.
        sol_balance:
          type: number
        usdc_balance:
          type: number
        vault_balance:
          type: number
          description: Gross USDC balance currently held in the caller's Totalis vault. Resolved by the returned `address`; returns 0 when no vault row exists for that wallet. Includes both free vault cash and locked collateral. Add to `usdc_balance` for total on-chain USDC across wallet and vault; subtract `locked_amount` from that total for an available-cash view.
        locked_amount:
          type: number
          description: 'Every USDC the caller has committed across roles: in-flight RFQs, bettor-side vault position stakes, and maker-side `locked_collateral`. Because `vault_balance` is gross, vault locked collateral is intentionally represented in both `vault_balance` and `locked_amount`; compute available cash as `usdc_balance + vault_balance - locked_amount` and do not add `/v1/vault.locked_collateral` again.'
        has_traded:
          type: boolean
          description: True once any of the caller's parlays has reached `executed` or `settled`. Informational account state; withdrawals are allowed regardless of this value.
    PortfolioData:
      type: object
      description: Consolidated portfolio response from GET /v1/portfolio.
      properties:
        balance:
          nullable: true
          description: Null when user has no vault (never traded)
          allOf:
          - $ref: '#/components/schemas/PortfolioBalance'
        stats:
          $ref: '#/components/schemas/UserStats'
        counts:
          $ref: '#/components/schemas/RfqCounts'
        summary:
          $ref: '#/components/schemas/PortfolioSummary'
    ErrorBody:
      type: object
      required:
      - code
      - message
      properties:
        code:
          type: string
          enum:
          - VALIDATION_ERROR
          - UNAUTHORIZED
          - FORBIDDEN
          - NOT_FOUND
          - CONFLICT
          - RATE_LIMITED
          - PAYLOAD_TOO_LARGE
          - INTERNAL_ERROR
          - SERVICE_UNAVAILABLE
          description: Machine-readable error code.
        message:
          type: string
          description: Human-readable error message.
        details:
          type: object
          description: Additional error context.
        retry_after:
          type: integer
          nullable: true
          description: Seconds until retry; set on 429 responses.
  responses:
    InternalError:
      description: Internal server error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    Unauthorized:
      description: Missing or invalid authentication
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            error:
              code: UNAUTHORIZED
              message: Missing or invalid authentication
    BadRequest:
      description: Validation error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    RateLimited:
      description: Rate limit exceeded
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    Conflict:
      description: Resource conflict (duplicate, already taken, invalid status transition, etc.)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      description: Client-generated UUID. Successful (2xx) responses are cached for 24h and replayed on retry with the same key. The request body is not part of the cache key — use a fresh UUID whenever the payload differs (e.g. updated odds), otherwise the original response is replayed. Non-2xx responses are not cached.
      schema:
        type: string
        format: uuid
  securitySchemes:
    PrivyJWT:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: 'Privy JWT issued to the web dashboard. Sent as `Authorization: Bearer <jwt>`. The Privy session signer underpins all wallet-signed actions.'
    ApiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: 'Programmatic API key. Sent as `X-API-Key: <key>`. Generate one from the Totalis dashboard. The same header is accepted on the WebSocket auth message.'