openapi: 3.1.0
info:
  title: rip.fun Mystery Pack Partner API
  version: "1.0.0"
  description: >
    Public B2B API for purchasing rip.fun Mystery Packs (the on-chain
    MysteryComboPool) on behalf of end-users, with a custodial credits wallet,
    non-custodial signing, sell-backs, physical redemption (the end user signs
    the burn and pays shipping on-chain; rip.fun runs no KYC/AML, so screening
    the end user is the partner's responsibility), revenue share +
    payouts, a card pricing feed, and webhooks. Also sells Instant Packs —
    booster packs bought + opened in a single on-chain tx, pre-bundled from
    physically ripped packs with the bundle selected by Chainlink VRF
    (custodial or non-custodial, delivery async). Partners settle on the TIER
    revenue-share model; the legacy POOL model (own inventory, per-item
    billing, pool wallet) is no longer offered and its endpoints are not in
    this spec.


    **Auth:** every request requires an `X-API-Key` header (a mystery-partner
    key). Keys are scope-gated; `admin` satisfies a partner's own endpoints but
    NOT the cross-partner `platform:admin` endpoints, and NOT the exclusive
    `payout:manage` scope (payout-wallet endpoints require it explicitly).


    **Rate limit:** 240 requests/min per API key.


    **Money:** all amounts are USDC. `*_micros` fields are integer USDC micros
    (6 dp) as strings; `*_usdc` fields are human decimal strings.
servers:
  - url: https://service.rip.fun
    description: Production (Base mainnet)
  - url: https://api.getcardos.com
    description: >
      Production (Base mainnet) — cardOS-branded alias. Same ALB, same target
      group, same service as service.rip.fun; identical routes and responses.
      Either host works with the same X-API-Key.
  - url: https://staging-service.rip.fun
    description: Staging (Base Sepolia)
tags:
  - name: Catalog
  - name: Wallet
  - name: Purchases
  - name: Instant Packs
    description: >
      Standalone feature — booster packs bought + opened in one on-chain tx
      (custodial or non-custodial, async VRF delivery). Markdown reference:
      docs/instant-pack-partner-api.md.
  - name: Buyback
  - name: Redemption
  - name: Pricing
  - name: Webhooks
  - name: Admin
security:
  - ApiKeyAuth: []
components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
  parameters:
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
    Offset:
      name: offset
      in: query
      schema: { type: integer, minimum: 0, default: 0 }
    ExternalUserId:
      name: external_user_id
      in: query
      schema: { type: string, maxLength: 255 }
      description: End-user identity (provide this OR wallet_address).
    WalletAddress:
      name: wallet_address
      in: query
      schema: { type: string }
      description: End-user 0x address (provide this OR external_user_id).
  schemas:
    Error:
      type: object
      properties:
        success: { type: boolean, example: false }
        message: { type: string }
        error: { type: string, description: Stable error code, e.g. insufficient_funds }
    Pagination:
      type: object
      properties:
        limit: { type: integer }
        offset: { type: integer }
        has_more: { type: boolean }
    Tier:
      type: object
      properties:
        tier_id: { type: integer }
        price_usdc: { type: string, nullable: true }
        price_display: { type: string, nullable: true }
        price_micros: { type: string, nullable: true }
        target_ev_usdc: { type: string, nullable: true }
        slot_count: { type: integer, nullable: true }
        active: { type: boolean, nullable: true }
        total_purchases: { type: integer }
        last_active_at: { type: string, format: date-time }
    Balance:
      type: object
      properties:
        currency: { type: string, example: USDC }
        available: { type: string }
        reserved: { type: string }
        balance: { type: string }
        available_usdc: { type: string }
        reserved_usdc: { type: string }
        balance_usdc: { type: string }
    Purchase:
      type: object
      properties:
        id: { type: integer }
        memo: { type: string, description: "{partner-slug}-{id} attribution tag" }
        status:
          type: string
          enum: [PENDING, RESERVED, SUBMITTING, SUBMITTED, FULFILLED, PARTIALLY_FULFILLED, REFUNDED, FAILED]
        custody: { type: string, enum: [CUSTODIAL, NON_CUSTODIAL] }
        tier_id: { type: integer }
        quantity: { type: integer }
        price_usdc: { type: string }
        volume_usdc: { type: string }
        onchain_request_id: { type: string, nullable: true }
        transaction_hash: { type: string, nullable: true }
        purchaser_address: { type: string, nullable: true }
        failure_reason: { type: string, nullable: true }
        items:
          type: array
          items: { $ref: "#/components/schemas/RevealItem" }
    RevealItem:
      type: object
      properties:
        token_id: { type: string }
        item_type: { type: string }
        category: { type: string, enum: [card, product, unknown] }
        card_id: { type: string, nullable: true, example: base1-4, description: Canonical Card Data API id — null for products and unmapped tokens }
        name: { type: string, nullable: true }
        image_url: { type: string, nullable: true }
        value_usd: { type: string, nullable: true }
        rarity: { type: string, nullable: true }
        card_number: { type: string, nullable: true }
        set_id: { type: string, nullable: true }
    CollectionItem:
      allOf:
        - $ref: "#/components/schemas/RevealItem"
        - type: object
          properties:
            purchase_id: { type: integer }
            tier_id: { type: integer }
            acquired_at: { type: string, format: date-time, nullable: true }
            still_owned: { type: boolean, nullable: true, description: Current on-chain holder == the user's wallet; false once sold back / redeemed / burned; null for non-cards or unknown wallet }
    InstantCatalogItem:
      type: object
      properties:
        packet_type_id: { type: integer, description: On-chain packet type id — pass to POST /instant/purchase }
        set_id: { type: string }
        set_name: { type: string }
        product_id: { type: string }
        name: { type: string }
        image_url: { type: string, nullable: true }
        large_image_url: { type: string, nullable: true }
        price: { type: string, description: Live on-chain price in USDC micros }
        price_usdc: { type: string }
        available_packs: { type: integer, description: Pre-built bundles left (0 -> purchases return 409 sold_out) }
        tcg_type: { type: string, example: pokemon }
        language: { type: string, example: ENGLISH }
    InstantPack:
      type: object
      description: The delivered (already-opened) pack — present only once FULFILLED.
      properties:
        unique_id: { type: string }
        set_id: { type: string }
        name: { type: string }
        image_url: { type: string, nullable: true }
        opened_at: { type: string, format: date-time, nullable: true }
    InstantCard:
      type: object
      properties:
        token_id: { type: string }
        unique_id: { type: string }
        card_id: { type: string, example: sv8pt5-160 }
        name: { type: string }
        card_number: { type: string, nullable: true }
        rarity: { type: string, nullable: true }
        is_chase: { type: boolean }
        small_image_url: { type: string, nullable: true }
        large_image_url: { type: string, nullable: true }
        front_image_url: { type: string, nullable: true }
        value_usd: { type: string, nullable: true, description: Card raw market price (USD decimal string) }
        set_id: { type: string }
    InstantPurchase:
      type: object
      properties:
        id: { type: integer }
        memo: { type: string, description: "{partner-slug}-instant-{id} attribution tag" }
        status:
          type: string
          enum: [PENDING, RESERVED, SUBMITTING, SUBMITTED, FULFILLED, REFUNDED, FAILED]
        custody: { type: string, enum: [CUSTODIAL, NON_CUSTODIAL] }
        packet_type_id: { type: integer }
        set_id: { type: string, nullable: true }
        product_id: { type: string, nullable: true }
        quantity: { type: integer, example: 1 }
        price: { type: string, description: USDC micros }
        price_usdc: { type: string }
        onchain_request_id: { type: string, nullable: true }
        transaction_hash: { type: string, nullable: true }
        purchaser_address: { type: string, nullable: true, description: Wallet that paid on-chain — relayer signer (custodial) or the user's own wallet (non-custodial) }
        recipient_address: { type: string, nullable: true, description: Wallet the cards were delivered to }
        failure_reason: { type: string, nullable: true }
        reserved_at: { type: string, format: date-time, nullable: true }
        submitted_at: { type: string, format: date-time, nullable: true }
        fulfilled_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        pack:
          description: Present only once FULFILLED.
          oneOf:
            - $ref: "#/components/schemas/InstantPack"
            - type: "null"
        cards:
          type: array
          description: Present only once FULFILLED.
          items: { $ref: "#/components/schemas/InstantCard" }
    ShippingAddress:
      type: object
      required: [name, street1, city, zip, country]
      properties:
        name: { type: string }
        street1: { type: string }
        street2: { type: string, nullable: true }
        city: { type: string }
        state: { type: string, nullable: true }
        zip: { type: string }
        country: { type: string }
        phone: { type: string, nullable: true }
        email: { type: string, nullable: true }
    ShippingQuote:
      type: object
      description: Cheapest live Shippo rate (snapshotted onto the redemption at prepare).
      properties:
        amount_usdc: { type: string }
        currency: { type: string, example: USD }
        carrier: { type: string, nullable: true }
        service: { type: string, nullable: true }
        estimated_days: { type: integer, nullable: true }
        rate_id: { type: string, nullable: true }
        quoted_at: { type: string, format: date-time }
    UnsignedCalls:
      type: object
      description: >
        Unsigned calldata the END USER signs + broadcasts from the wallet
        holding the NFT (same shape as /purchase/prepare).
      properties:
        chain_id: { type: integer, example: 8453 }
        calls:
          type: array
          items:
            type: object
            properties:
              to: { type: string }
              data: { type: string }
              kind:
                type: string
                enum: [erc20-approve, purchase, set-approval-for-all, sellback, burn, shipping-payment]
                description: Machine-readable call type — dispatch on this, not the description or 4-byte selector (AA wallets must send approves as their own userop)
              description: { type: string, example: Initiate redemption burn (CARD) }
    RedemptionShippingPayment:
      type: object
      description: >
        The end user's on-chain shipping payment: the EIP-712 quote the
        payShipping call carries, echoed so you can display/verify it without
        decoding calldata. Single-use, payer-bound, amount-locked; the
        signature expires with the prepare (expiration_time == expires_at).
      properties:
        payer: { type: string, description: The holder wallet that must fund the payment }
        processor: { type: string, description: ShippingPaymentProcessor contract address }
        total_usdc: { type: string, example: "5.420000" }
        total_micros: { type: string, example: "5420000" }
        expiration_time: { type: integer, description: Quote expiry (unix seconds) }
        provider_order_id: { type: string, description: The internal order the payment settles }
    RedemptionPrepareResult:
      type: object
      properties:
        redemption_id: { type: integer }
        status:
          type: string
          enum: [PREPARED, BURN_SUBMITTED, IN_FULFILLMENT, COMPLETED, FAILED, CANCELLED, EXPIRED]
        item_type: { type: string, enum: [CARD, GRADED_CARD, PACKET, SEALED_PRODUCT] }
        token_id: { type: string }
        shipping_quote:
          oneOf:
            - $ref: "#/components/schemas/ShippingQuote"
            - type: "null"
        shipping_payer:
          type: string
          enum: [partner, end_user]
          description: >
            Who funds shipping. Every new redemption is end_user (the partner
            is never billed); partner appears only on legacy rows prepared
            before on-chain shipping payment existed.
        shipping_payment:
          description: >
            Present while the end user's shipping payment is still due (and the
            payment calls are in `unsigned.calls`); null otherwise.
          oneOf:
            - $ref: "#/components/schemas/RedemptionShippingPayment"
            - type: "null"
        expires_at: { type: string, format: date-time, nullable: true }
        unsigned:
          description: >
            Calldata the end user signs, present from PREPARED onwards.
            Carries the initiateBurn plus, while payment is due, the
            erc20-approve and shipping-payment calls. Keep the order: burn
            first (payShipping verifies the burn state), and send the approve
            as its own userop under account abstraction.
          oneOf:
            - $ref: "#/components/schemas/UnsignedCalls"
            - type: "null"
    Redemption:
      type: object
      properties:
        redemption_id: { type: integer }
        purchase_id: { type: [integer, "null"], description: null for token-first redemptions }
        token_id: { type: string }
        item_type: { type: string, enum: [CARD, GRADED_CARD, PACKET, SEALED_PRODUCT] }
        status:
          type: string
          enum: [PREPARED, BURN_SUBMITTED, IN_FULFILLMENT, COMPLETED, FAILED, CANCELLED, EXPIRED]
        burn:
          type: object
          properties:
            db_status: { type: string, nullable: true, example: PENDING_REDEEM }
            is_burned: { type: boolean, description: Burn finalized (at physical dispatch) }
            burn_type: { type: integer, nullable: true, example: 1 }
            burn_type_label: { type: string, nullable: true, example: REDEEM_CARD }
        queue:
          type: object
          properties:
            status: { type: string, nullable: true, example: PENDING }
        order:
          type: object
          properties:
            status: { type: string, nullable: true }
            tracking_number: { type: string, nullable: true }
        burn_tx_hash: { type: string, nullable: true }
        shipping_quote:
          oneOf:
            - $ref: "#/components/schemas/ShippingQuote"
            - type: "null"
        failure_reason: { type: string, nullable: true }
        expires_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    PoolWallet:
      type: object
      properties:
        chain: { type: string, example: base }
        address: { type: string, description: Checksummed EVM address }
        is_active: { type: boolean }
        created_at: { type: string, format: date-time }
    Price:
      type: object
      properties:
        card_id: { type: string }
        token_id: { type: string, nullable: true, description: Echo of the token_id query (null when queried by card_id) }
        market_value_usdc: { type: string, description: Canonical card value (raw_price; oracle RAW price fallback) }
        buyback_price_usdc: { type: string, description: MYSTERY_BUYBACK_PCT% (default 85) of market value }
        updated_at: { type: string, format: date-time, nullable: true, description: Last oracle refresh (null when priced from raw_price only) }
  responses:
    Unauthorized:
      description: Missing/invalid/expired API key
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: Insufficient scope, or not a mystery-partner key
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
paths:
  /api/v1/mystery/catalog:
    get:
      tags: [Catalog]
      summary: List active tiers (price/EV/odds read live on-chain)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      tiers:
                        type: array
                        items: { $ref: "#/components/schemas/Tier" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /api/v1/mystery/catalog/{tier_id}/odds:
    get:
      tags: [Catalog]
      summary: Authoritative per-rarity-group odds + EV for a tier (on-chain weights)
      parameters:
        - name: tier_id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: OK
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/mystery/feed/recent:
    get:
      tags: [Catalog]
      summary: Recent pulls (add scope=mine to filter to this partner)
      description: Rows are unique on (token_id, revealed_at), not token_id — a re-pulled card appears once per reveal. Each row carries tier_id (integer) and game {id,label}|null.
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Offset" }
        - name: scope
          in: query
          schema: { type: string, enum: [mine] }
        - name: game
          in: query
          description: Filter to one game (catalog ids, e.g. onepiece)
          schema: { type: string }
      responses:
        "200": { description: OK }
  /api/v1/mystery/feed/winners:
    get:
      tags: [Catalog]
      summary: Top recent pulls by item value
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Offset" }
        - name: scope
          in: query
          schema: { type: string, enum: [mine] }
      responses:
        "200": { description: OK }
  /api/v1/wallet/balance:
    get:
      tags: [Wallet]
      summary: Custodial credits balance for an end-user
      parameters:
        - { $ref: "#/components/parameters/ExternalUserId" }
        - { $ref: "#/components/parameters/WalletAddress" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      wallet: { $ref: "#/components/schemas/Balance" }
  /api/v1/wallet/ledger:
    get:
      tags: [Wallet]
      summary: Append-only credits ledger
      parameters:
        - { $ref: "#/components/parameters/ExternalUserId" }
        - { $ref: "#/components/parameters/WalletAddress" }
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Offset" }
      responses:
        "200": { description: OK }
  /api/v1/wallet/deposits:
    get:
      tags: [Wallet]
      summary: USDC deposit history + status
      parameters:
        - { $ref: "#/components/parameters/ExternalUserId" }
        - { $ref: "#/components/parameters/WalletAddress" }
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Offset" }
      responses:
        "200": { description: OK }
  /api/v1/wallet/deposit-address:
    post:
      tags: [Wallet]
      summary: Get/provision the USDC-on-Base deposit address for an end-user
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                external_user_id: { type: string }
                wallet_address: { type: string }
      responses:
        "200": { description: OK }
        "429":
          description: Per-partner end-user cap reached
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /api/v1/mystery/purchase:
    post:
      tags: [Purchases]
      summary: Custodial purchase (reserve credits, fulfill async via VRF)
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tier_id]
              properties:
                tier_id: { type: integer }
                external_user_id: { type: string }
                wallet_address: { type: string }
                max_price_usdc: { type: string }
      responses:
        "202":
          description: Accepted (RESERVED) — poll the purchase for status
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data: { $ref: "#/components/schemas/Purchase" }
        "402":
          description: Insufficient funds
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /api/v1/mystery/purchases:
    get:
      tags: [Purchases]
      summary: Paginated purchase history
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Offset" }
        - name: status
          in: query
          schema: { type: string }
        - { $ref: "#/components/parameters/ExternalUserId" }
        - { $ref: "#/components/parameters/WalletAddress" }
      responses:
        "200": { description: OK }
  /api/v1/mystery/collection:
    get:
      tags: [Purchases]
      summary: One end user's collection — every item delivered through your fulfilled purchases
      description: Newest first, hydrated with the canonical card_id and a still_owned flag. Identity is required (external_user_id or wallet_address).
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Offset" }
        - { $ref: "#/components/parameters/ExternalUserId" }
        - { $ref: "#/components/parameters/WalletAddress" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      user:
                        type: object
                        properties:
                          external_user_id: { type: string, nullable: true }
                          wallet_address: { type: string, nullable: true }
                      items:
                        type: array
                        items: { $ref: "#/components/schemas/CollectionItem" }
        "400": { description: Missing/invalid user identifier }
  /api/v1/mystery/stats:
    get:
      tags: [Purchases]
      summary: Per-partner pull + buyback volume
      responses:
        "200": { description: OK }
  /api/v1/mystery/purchase/prepare:
    post:
      tags: [Purchases]
      summary: Non-custodial — return unsigned approve + purchase calldata
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tier_id, wallet_address]
              properties:
                tier_id: { type: integer }
                wallet_address: { type: string }
                max_price_usdc: { type: string }
      responses:
        "200": { description: "OK — { chain_id, payment_token, contract, max_price, calls[] }" }
  /api/v1/mystery/purchase/submit:
    post:
      tags: [Purchases]
      summary: Non-custodial — record a broadcast purchase tx (verified on-chain)
      description: >
        The backend verifies the tx on-chain (must be mined, succeeded, and emit a
        MysteryComboPool purchase for the given tier). The requestId/price are
        taken from the receipt, not the request body.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tier_id, transaction_hash]
              properties:
                tier_id: { type: integer }
                transaction_hash: { type: string }
                external_user_id: { type: string }
                wallet_address: { type: string }
      responses:
        "202": { description: Accepted (SUBMITTED) }
        "400":
          description: tx_unverified / tx_reverted / tier_mismatch / purchaser_mismatch
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /api/v1/mystery/purchase/{purchase_id}:
    get:
      tags: [Purchases]
      summary: Purchase status + revealed items
      parameters:
        - name: purchase_id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data: { $ref: "#/components/schemas/Purchase" }
        "404": { description: Not found }
  /api/v1/instant/catalog:
    get:
      tags: [Instant Packs]
      summary: Packet types purchasable as instant packs (cached ~60s)
      description: >
        Requires the read:catalog scope. An instant pack is bought + opened in
        a single on-chain tx — cards are pre-bundled from physically ripped
        packs and Chainlink VRF selects the delivered bundle. By default only
        packet types with available_packs > 0 are listed.
      parameters:
        - name: include_unavailable
          in: query
          schema: { type: string, enum: ["1"] }
          description: Also list packet types with no bundles left.
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Offset" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      packs:
                        type: array
                        items: { $ref: "#/components/schemas/InstantCatalogItem" }
                  pagination: { $ref: "#/components/schemas/Pagination" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /api/v1/instant/purchase:
    post:
      tags: [Instant Packs]
      summary: Custodial instant-pack purchase (buy + open in one tx; fulfill async via VRF)
      description: >
        Requires the packs:purchase scope. Custodial flow — the price is
        reserved from the end-user's custodial credits wallet (the same wallet
        system as mystery packs) and a relayer executes the on-chain purchase
        (for non-custodial use /instant/purchase/prepare + /submit).
        Delivery is asynchronous (usually a few seconds, up to ~90s): poll
        GET /instant/purchase/{purchase_id} or subscribe to the
        instant_purchase.* webhooks. Idempotent on (partner, Idempotency-Key);
        reusing a key with a different packet_type_id -> 409
        idempotency_mismatch.
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [packet_type_id]
              properties:
                packet_type_id: { type: integer, description: From GET /instant/catalog }
                external_user_id: { type: string }
                wallet_address: { type: string }
                max_price_usdc: { type: string, description: Fails 409 max_price_exceeded if the live on-chain price is higher; the user is debited the actual price }
                idempotency_key: { type: string, description: Alternative to the Idempotency-Key header }
      responses:
        "202":
          description: Accepted (RESERVED) — poll the purchase for status
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data: { $ref: "#/components/schemas/InstantPurchase" }
        "400":
          description: invalid_packet_type / missing_idempotency_key
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "402":
          description: insufficient_funds (custodial balance too low)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: idempotency_mismatch / sold_out / max_price_exceeded
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: instant_disabled (instant opening switched off) / relayer_disabled
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /api/v1/instant/purchase/{purchase_id}:
    get:
      tags: [Instant Packs]
      summary: Instant purchase status + delivered pack/cards once FULFILLED
      description: >
        Requires the packs:read scope. pack + cards are present only once the
        purchase is FULFILLED.
      parameters:
        - name: purchase_id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data: { $ref: "#/components/schemas/InstantPurchase" }
        "404":
          description: not_found (not this partner's purchase)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /api/v1/instant/purchases:
    get:
      tags: [Instant Packs]
      summary: Paginated instant purchase history
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Offset" }
        - name: status
          in: query
          schema:
            type: string
            enum: [PENDING, RESERVED, SUBMITTING, SUBMITTED, FULFILLED, REFUNDED, FAILED]
        - { $ref: "#/components/parameters/ExternalUserId" }
        - { $ref: "#/components/parameters/WalletAddress" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      purchases:
                        type: array
                        items: { $ref: "#/components/schemas/InstantPurchase" }
                  pagination: { $ref: "#/components/schemas/Pagination" }
  /api/v1/instant/purchase/prepare:
    post:
      tags: [Instant Packs]
      summary: Non-custodial — return unsigned approve + purchaseInstantOpenFor calldata
      description: >
        Requires the packs:purchase scope. Returns the unsigned USDC approve +
        RipFunStore.purchaseInstantOpenFor(packetTypeId, buyer, price) calls
        for the end user's OWN wallet to sign and broadcast — wallet_address is
        the wallet that signs, pays and receives the cards. price is the live
        on-chain price; a max_price_usdc below it -> 409 max_price_exceeded.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [packet_type_id, wallet_address]
              properties:
                packet_type_id: { type: integer, description: From GET /instant/catalog }
                wallet_address: { type: string, description: The end user's own wallet that will sign and pay }
                max_price_usdc: { type: string }
      responses:
        "200":
          description: "OK — { chain_id, payment_token, contract, packet_type_id, price, price_usdc, calls[] }"
        "400":
          description: invalid_packet_type / invalid_wallet_address
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: sold_out / max_price_exceeded
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: instant_disabled (instant opening switched off)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /api/v1/instant/purchase/submit:
    post:
      tags: [Instant Packs]
      summary: Non-custodial — record a broadcast instant purchase tx
      description: >
        Requires the packs:purchase scope. Records the user-broadcast purchase
        tx. The tx is verified on-chain before anything is written — the
        receipt must contain a RipFunStore instant-pack purchase event, and
        the packet type, price, request id and recipient are taken from the
        receipt, never from the request. Creates the purchase with custody
        NON_CUSTODIAL and status SUBMITTED — no custodial ledger touch; the
        cards (and any on-chain refund) go to the user's own wallet.
        Re-submitting the same tx hash is idempotent. Fulfillment is driven by
        the same on-chain events as custodial purchases: poll
        GET /instant/purchase/{purchase_id} or use the instant_purchase.*
        webhooks.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [packet_type_id, transaction_hash]
              properties:
                packet_type_id: { type: integer }
                transaction_hash: { type: string }
                external_user_id: { type: string }
                wallet_address: { type: string }
      responses:
        "202":
          description: Accepted (SUBMITTED) — fulfillment is asynchronous
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data: { $ref: "#/components/schemas/InstantPurchase" }
        "400":
          description: >
            missing_tx_hash / invalid_packet_type / tx_unverified (not mined
            yet or not a RipFunStore instant purchase) / tx_reverted /
            packet_type_mismatch / purchaser_mismatch
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: tx_already_recorded — the tx belongs to another purchase
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /api/v1/mystery/buyback:
    post:
      tags: [Buyback]
      summary: Create a buyback offer directly from a token id (PRIMARY — no purchase needed)
      description: >
        Token-first buyback: works whether or not the pull happened through this
        API — partners distributing rip.fun cards through their own systems buy
        back any card they can name by token id. Raw Card tokens are priced from
        card.raw_price (fallback: the RAW buyback-pricing oracle); GradedCard
        tokens are priced from the graded market price for that exact
        (card, grading company, grade) and are NEVER priced from the raw card
        value (no graded price -> 409 value_unknown). The raw token id of a
        since-graded card resolves to its GradedCard token automatically. The
        offer targets the token's current holder. Idempotent per
        (partner, token) — an existing active offer is returned rather than
        re-signed; any other active mystery offer on the token -> 409
        offer_exists (double-payout guard).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token_id]
              properties:
                token_id: { type: string, description: on-chain token id (raw Card or GradedCard NFT) }
                item_type:
                  type: string
                  enum: [CARD, GRADED_CARD]
                  description: >
                    Required only to disambiguate when the same numeric token id
                    exists as both a raw Card and a graded GradedCard (else
                    409 ambiguous_token). A value that contradicts the resolved
                    token -> 409 item_type_mismatch.
                offer_price_usdc: { type: string, description: optional override; cannot exceed the item value }
      responses:
        "201":
          description: >
            Offer created (or existing active offer returned). Adds item_type
            (CARD | GRADED_CARD), token_id (the token actually offered),
            value_usdc and value_source (card_raw_price | buyback_oracle |
            graded_market_price) to the standard offer payload.
        "400":
          description: token_required / price_too_high / invalid_item_type
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: token_not_found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: token_burned / not_onchain / no_holder / not_eligible / value_unknown / offer_exists / ambiguous_token / item_type_mismatch / unsupported_item
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    get:
      tags: [Buyback]
      summary: Buyback offers for an on-chain token (this partner only)
      description: >
        Returns the partner's buyback offers on the token across both forms —
        token-first offers and purchase-linked offers (purchase_id is null for
        token-first ones). The raw token id of a graded card resolves to the
        GradedCard token.
      parameters:
        - name: token_id
          in: query
          required: true
          schema: { type: string }
      responses:
        "200": { description: OK }
        "400":
          description: token_required
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /api/v1/mystery/buyback/{purchase_id}:
    post:
      tags: [Buyback]
      summary: Create a marketplace buyback offer by purchase id OR token id (85% of card value; $10–$100 cards)
      description: >
        Purchase-linked variant of POST /mystery/buyback. The path segment
        accepts EITHER a purchase id or an on-chain token id: when it matches one
        of your purchases the purchase-linked path runs (idempotent per
        purchase+token, an existing active offer is returned rather than
        re-signed); otherwise it is treated as a token id and the behaviour is
        identical to POST /mystery/buyback (token-first — raw + graded, with the
        item_type body disambiguator and value_usdc/value_source in the
        response). Purchase ids take precedence; use POST /mystery/buyback with
        token_id in the body for an unambiguous token-first offer.
      parameters:
        - name: purchase_id
          in: path
          required: true
          description: A purchase id, or — when it isn't one of your purchases — an on-chain token id.
          schema: { type: string }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                token_id: { type: string, description: required if the (purchase) has multiple items; ignored on the token-id path }
                item_type:
                  type: string
                  enum: [CARD, GRADED_CARD]
                  description: token-id path only — disambiguates a numeric id shared by a raw Card and a graded GradedCard (else 409 ambiguous_token)
                offer_price_usdc: { type: string, description: optional override; cannot exceed card value }
      responses:
        "201": { description: Offer created (or existing active offer returned) }
        "404":
          description: token_not_found (token-id path — neither a purchase of yours nor a known token)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: not_fulfilled / not_eligible / unsupported_item / token_burned / not_onchain / no_holder / value_unknown / offer_exists / ambiguous_token / item_type_mismatch
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    get:
      tags: [Buyback]
      summary: Buyback offer status by purchase id OR token id
      description: >
        The path segment accepts a purchase id or an on-chain token id. A value
        that isn't one of your purchases is resolved token-first (identical to
        GET /mystery/buyback?token_id=…, so each offer also carries purchase_id —
        null for token-first ones).
      parameters:
        - name: purchase_id
          in: path
          required: true
          description: A purchase id, or — when it isn't one of your purchases — an on-chain token id.
          schema: { type: string }
      responses:
        "200": { description: OK }
  /api/v1/mystery/price:
    get:
      tags: [Pricing]
      summary: Market value + buyback price for a card (cached ~60s)
      description: >
        Requires the read:catalog scope. Exactly one of card_id / token_id must
        be supplied (else 400 invalid_price_query). token_id resolves via the
        on-chain token → card mapping (graded tokens included).
      parameters:
        - name: card_id
          in: query
          schema: { type: string }
        - name: token_id
          in: query
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data: { $ref: "#/components/schemas/Price" }
        "400":
          description: invalid_price_query (exactly one of card_id / token_id)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: not_found (unknown card/token) / value_unknown (no market price available)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /api/v1/mystery/redemption/quote:
    post:
      tags: [Redemption]
      summary: Shipping quote pre-checkout (no rows created) — by purchase id OR token id
      description: >
        Requires the cards:redeem scope. Validates the address via Shippo and
        returns the cheapest live rate so shipping can be shown at checkout.
        Supply purchase_id OR token_id (token_id alone for a token-first / own-
        gacha redemption; with purchase_id it disambiguates a multi-item
        purchase). Creates no rows — not even an end-user on the token-first path.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [shipping_address]
              properties:
                purchase_id: { type: integer, description: supply this OR token_id }
                token_id: { type: string, description: on-chain token id; supply this OR purchase_id }
                shipping_address: { $ref: "#/components/schemas/ShippingAddress" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      shipping_quote: { $ref: "#/components/schemas/ShippingQuote" }
        "400":
          description: purchase_or_token_required / missing_shipping_address / invalid_address / token_required
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: token_not_found (token-first path) / not_found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: not_fulfilled / ambiguous_token / no_holder
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /api/v1/mystery/redemption/prepare:
    post:
      tags: [Redemption]
      summary: Prepare a redemption (by purchase id OR token id) — unsigned burn calldata
      description: >
        Requires the cards:redeem scope. Supply purchase_id OR token_id. With
        token_id alone this is a TOKEN-FIRST redemption (own-gacha cards we
        supplied, no purchase): the burn signer is the token's current on-chain
        holder, resolved automatically, and the redemption carries
        purchase_id=null. Validates eligibility (token unburned, on-chain live,
        still held by the holder, no active redeem-queue entry; purchase path
        also requires the purchase fulfilled), stores the Shippo address + rate
        snapshot and pre-creates the fulfillment rows. CardOS runs no identity
        (KYC) or AML checks here — screening the end user is the partner's
        responsibility, and prepare answers immediately for any eligible card
        the holder owns. The response is PREPARED, with `unsigned` carrying the
        initiateBurn calldata PLUS the end user's on-chain shipping payment
        (erc20-approve + payShipping with a server-signed quote — the END USER
        pays shipping; partners are never billed). All calls are valid until
        expires_at (default 24h — EXPIRED redemptions are retryable with a new
        idempotency key). The order ships only once the ShippingPaid event
        lands. Idempotent on (partner, idempotency_key); at most one live
        redemption per (partner, token).
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [shipping_address]
              properties:
                purchase_id: { type: integer, description: supply this OR token_id }
                token_id: { type: string, description: on-chain token id (token-first); with purchase_id, disambiguates a multi-item purchase }
                shipping_address: { $ref: "#/components/schemas/ShippingAddress" }
                idempotency_key: { type: string, description: alternative to the Idempotency-Key header }
                shipping_payer:
                  type: string
                  enum: [end_user]
                  description: >
                    Optional (forward-compatibility). end_user is the only
                    valid value — the end user always pays the quoted shipping
                    on-chain; partner-billed shipping is not offered
                    (400 invalid_shipping_payer otherwise).
      responses:
        "200":
          description: OK — PREPARED, with the calldata to send
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data: { $ref: "#/components/schemas/RedemptionPrepareResult" }
        "400":
          description: purchase_or_token_required / missing_shipping_address / invalid_address / token_required / invalid_shipping_payer
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { description: Purchase / token not found (token_not_found on the token-first path) }
        "409":
          description: not_fulfilled / redemption_exists / not_burnable / not_owner / no_holder / ambiguous_token / contract_unconfigured / invalid_token_id
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: shipping_signer_unavailable — the shipping quote signer is not currently authorized on-chain; retry later
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /api/v1/mystery/redemption/submit:
    post:
      tags: [Redemption]
      summary: Record the user-broadcast burn tx (PREPARED → BURN_SUBMITTED)
      description: >
        Requires the cards:redeem scope. Idempotent for the same tx_hash. The
        sync worker also converges from on-chain state, so an unsubmitted burn
        still advances the redemption.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [redemption_id, tx_hash]
              properties:
                redemption_id: { type: integer }
                tx_hash: { type: string, description: 0x-prefixed 32-byte tx hash }
      responses:
        "202":
          description: Accepted (BURN_SUBMITTED) — fulfillment is asynchronous
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      redemption_id: { type: integer }
                      status: { type: string, example: BURN_SUBMITTED }
                      burn_tx_hash: { type: string }
        "400":
          description: invalid_tx_hash
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { description: Redemption not found }
        "409":
          description: invalid_status (the redemption is not PREPARED)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /api/v1/mystery/redemption/{purchase_id}:
    get:
      tags: [Redemption]
      summary: Redemption status by purchase id OR token id (one entry per redeemed token)
      description: >
        Requires the packs:read scope. Never cached. The path segment accepts a
        purchase id OR an on-chain token id — a value that isn't one of your
        purchases is resolved token-first (covers own-gacha redemptions, whose
        purchase_id is null). verify=true forces an on-chain getItemBurnType
        read; otherwise burn_type reflects DB state once the burn has been
        observed.
      parameters:
        - name: purchase_id
          in: path
          required: true
          description: A purchase id, or — when it isn't one of your purchases — an on-chain token id.
          schema: { type: string }
        - name: verify
          in: query
          schema: { type: boolean, default: false }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      redemptions:
                        type: array
                        items: { $ref: "#/components/schemas/Redemption" }
        "404": { description: No redemptions for this purchase }
  /api/v1/mystery/redemptions:
    get:
      tags: [Redemption]
      summary: Paginated partner-wide redemption history
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Offset" }
        - name: status
          in: query
          schema:
            type: string
            enum: [PREPARED, BURN_SUBMITTED, IN_FULFILLMENT, COMPLETED, FAILED, CANCELLED, EXPIRED]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      redemptions:
                        type: array
                        items: { $ref: "#/components/schemas/Redemption" }
                  pagination: { $ref: "#/components/schemas/Pagination" }
        "400": { description: invalid_status }
  /api/v1/webhooks:
    get:
      tags: [Webhooks]
      summary: List registered webhooks
      responses:
        "200": { description: OK }
    post:
      tags: [Webhooks]
      summary: Register a webhook (returns the signing secret ONCE)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri }
                event_types:
                  type: array
                  items:
                    type: string
                    enum:
                      - deposit.credited
                      - purchase.reserved
                      - purchase.submitted
                      - purchase.fulfilled
                      - purchase.refunded
                      - purchase.failed
                      - instant_purchase.reserved
                      - instant_purchase.submitted
                      - instant_purchase.fulfilled
                      - instant_purchase.refunded
                      - instant_purchase.failed
                      - buyback.confirmed
                      - buyback.transfer_held
                      - buyback.card_transferred
                      - buyback.transfer_failed
                      - redemption.prepared
                      - redemption.updated
                      - pool.item_pulled
                      - sellback.confirmed
                      - payout.statement_ready
                      - payout.paid
      responses:
        "201": { description: Created — includes signing_secret (shown once) }
        "409": { description: webhook_limit reached }
  /api/v1/webhooks/deliveries:
    get:
      tags: [Webhooks]
      summary: Delivery log (debugging)
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Offset" }
      responses:
        "200": { description: OK }
  /api/v1/webhooks/{id}:
    get:
      tags: [Webhooks]
      summary: Fetch one webhook (no secret)
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200": { description: OK }
        "404": { description: Not found }
    delete:
      tags: [Webhooks]
      summary: Delete a webhook
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200": { description: Deleted }
        "404": { description: Not found }
  /api/v1/mystery/admin/buybacks:
    get:
      tags: [Admin]
      summary: Cross-partner buyback volume (requires platform:admin scope)
      description: Not reachable by a partner key, even one with the `admin` scope.
      responses:
        "200": { description: OK }
        "403": { $ref: "#/components/responses/Forbidden" }
webhooks:
  mysteryEvent:
    post:
      summary: Delivered event (signed)
      description: >
        POSTed to your registered URL. Verify the `X-Mystery-Signature`
        (`t=<ts>,sha256=<hmac>`) over `<ts>.<body>` using your signing secret;
        reject stale timestamps. Headers: X-Mystery-Event, X-Mystery-Delivery,
        X-Mystery-Timestamp.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                event: { type: string }
                id: { type: string }
                data: { type: object }
      responses:
        "200": { description: Acknowledged (any 2xx) }
