openapi: 3.1.0
info:
  title: Frag Public API
  version: "1.3.1"
  description: |
    Accept one payment from many coins, tokens, and chains. Receive one clean settlement.
    All endpoints live under `/api/public/v1/*` and require a bearer secret key
    (`sk_test_…` or `sk_live_…`) except where noted (`/api/public/health`,
    `/api/public/v1/tokens`, and `/api/public/v1/checkout/{id}` are unauthenticated).
  contact:
    name: Frag
    url: https://frag.cash
servers:
  - url: https://fragpay.lovable.app
    description: Production
  - url: https://frag.cash
    description: Production (custom domain)
security:
  - bearerAuth: []
tags:
  - name: Health
  - name: Payment intents
  - name: Checkout
  - name: Refunds
  - name: Payouts
  - name: Webhook endpoints
  - name: Tokens
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: sk_test_… / sk_live_…
  parameters:
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      required: false
      schema: { type: string, maxLength: 255 }
      description: Safe-retry key. Replays within 24h return the original response; same key + different body returns 409.
  schemas:
    Error:
      type: object
      properties:
        error: { type: string }
        code: { type: string }
        details: { type: object, additionalProperties: true }
    PaymentIntent:
      type: object
      properties:
        id: { type: string, format: uuid }
        amount: { type: string }
        currency: { type: string }
        mode: { type: string, enum: [test, live] }
        status:
          type: string
          enum:
            [
              created,
              requires_payment,
              quoted,
              awaiting_signature,
              executing,
              partially_filled,
              settled,
              failed,
              expired,
              cancelled,
              refunded,
            ]
        checkout_url: { type: string, format: uri }
        settlement_chain:
          type: string
          description: "Settlement chain. One of: robinhood, solana, ethereum, base, arbitrum, optimism, polygon, bnb, avalanche."
          enum: [robinhood, solana, ethereum, base, arbitrum, optimism, polygon, bnb, avalanche]
        settlement_token: { type: string }
        settlement_address: { type: string }
        customer_email: { type: string, nullable: true }
        expires_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        metadata: { type: object, additionalProperties: true }
    RouteCandidate:
      type: object
      properties:
        id: { type: string }
        provider: { type: string, description: "lifi-default | lifi-cctp | jupiter-direct | ..." }
        chosen: { type: boolean }
        ai_pick: { type: boolean }
        score: { type: number }
        from_chain: { type: string }
        to_chain: { type: string }
        from_token: { type: string }
        to_token: { type: string }
        to_amount_usd: { type: number }
        fee_usd: { type: number }
        gas_usd: { type: number }
        duration_sec: { type: integer }
        reason: { type: string, nullable: true }
        error: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
    Refund:
      type: object
      properties:
        id: { type: string, format: uuid }
        payment_intent_id: { type: string, format: uuid }
        amount_usd: { type: number }
        chain: { type: string }
        token: { type: string }
        destination: { type: string }
        reason: { type: string }
        status: { type: string, enum: [pending, paid, failed, cancelled] }
        tx_hash: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        paid_at: { type: string, nullable: true }
    Payout:
      type: object
      properties:
        id: { type: string, format: uuid }
        payment_intent_id: { type: string, nullable: true }
        rail: { type: string }
        destination: { type: string }
        chain: { type: string }
        token: { type: string }
        amount: { type: string }
        currency: { type: string }
        status:
          {
            type: string,
            enum:
              [
                queued,
                pending,
                processing,
                pending_provider,
                retry_scheduled,
                paid,
                failed,
                paused,
              ],
          }
        tx_hash: { type: string, nullable: true }
        failure_reason: { type: string, nullable: true }
        attempts: { type: integer }
        next_attempt_at: { type: string, nullable: true }
        paid_at: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
    WebhookEndpoint:
      type: object
      properties:
        id: { type: string, format: uuid }
        url: { type: string, format: uri }
        events: { type: array, items: { type: string } }
        enabled: { type: boolean }
        created_at: { type: string, format: date-time }
        secret:
          type: string
          description: Signing secret. Returned only in create + rotate-secret responses. Store immediately.
    Token:
      type: object
      properties:
        chain: { type: string }
        address: { type: string }
        symbol: { type: string }
        name: { type: string }
        decimals: { type: integer }
        verified: { type: boolean }
    CheckoutView:
      type: object
      properties:
        id: { type: string, format: uuid }
        amount: { type: string }
        currency: { type: string }
        status: { type: string }
        settlement_chain: { type: string }
        settlement_token: { type: string }
        expires_at: { type: string, format: date-time }
        legs:
          type: array
          items:
            type: object
            properties:
              chain: { type: string }
              token: { type: string }
              amount_usd: { type: number }
              status: { type: string }
paths:
  /api/public/health:
    get:
      tags: [Health]
      summary: Health check (unauthenticated)
      security: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: ok }
                  ts: { type: string, format: date-time }
  /api/public/v1/payment-intents:
    post:
      tags: [Payment intents]
      summary: Create a payment intent
      operationId: createPaymentIntent
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                amount: { type: number, minimum: 0.01, maximum: 1000000 }
                currency: { type: string, default: USD }
                settlement_chain:
                  type: string
                  description: "Settlement chain. One of: robinhood, solana, ethereum, base, arbitrum, optimism, polygon, bnb, avalanche."
                  enum: [robinhood, solana, ethereum, base, arbitrum, optimism, polygon, bnb, avalanche]
                settlement_token: { type: string }
                settlement_address: { type: string }
                customer_email: { type: string, format: email }
                expires_in_seconds: { type: integer, minimum: 60, maximum: 2592000 }
                metadata: { type: object, additionalProperties: true }
      responses:
        "201":
          description: Created
          content: { application/json: { schema: { $ref: "#/components/schemas/PaymentIntent" } } }
        "400":
          {
            description: Invalid input,
            content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } },
          }
        "401": { description: Unauthorized }
        "409":
          {
            description: Idempotency key mismatch,
            content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } },
          }
        "429": { description: Rate limited }
    get:
      tags: [Payment intents]
      summary: List payment intents
      operationId: listPaymentIntents
      parameters:
        - { in: query, name: limit, schema: { type: integer, maximum: 100, default: 20 } }
        - { in: query, name: status, schema: { type: string } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/PaymentIntent" } }
  /api/public/v1/payment-intents/{id}:
    parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
    get:
      tags: [Payment intents]
      summary: Retrieve a payment intent
      operationId: retrievePaymentIntent
      responses:
        "200":
          {
            description: OK,
            content:
              { application/json: { schema: { $ref: "#/components/schemas/PaymentIntent" } } },
          }
        "404": { description: Not found }
  /api/public/v1/payment-intents/{id}/cancel:
    parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
    post:
      tags: [Payment intents]
      summary: Cancel a payment intent
      operationId: cancelPaymentIntent
      responses:
        "200":
          {
            description: Cancelled,
            content:
              { application/json: { schema: { $ref: "#/components/schemas/PaymentIntent" } } },
          }
        "409": { description: Cannot cancel in current status }
  /api/public/v1/payment-intents/{id}/transactions:
    parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
    get:
      tags: [Payment intents]
      summary: List transaction legs contributing to this intent
      operationId: listPaymentIntentTransactions
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { type: object, additionalProperties: true } }
  /api/public/v1/payment-intents/{id}/routing:
    parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
    get:
      tags: [Payment intents]
      summary: List router candidates evaluated for this intent (chosen + rejected)
      operationId: listPaymentIntentRoutingCandidates
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/RouteCandidate" } }
        "404": { description: Not found }
  /api/public/v1/checkout/{id}:
    parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
    get:
      tags: [Checkout]
      summary: Public read-only checkout view (no auth). Used by hosted checkout + polling clients.
      operationId: getCheckoutView
      security: []
      responses:
        "200":
          {
            description: OK,
            content:
              { application/json: { schema: { $ref: "#/components/schemas/CheckoutView" } } },
          }
        "404": { description: Not found }
  /api/public/v1/refunds:
    post:
      tags: [Refunds]
      summary: Create a refund
      operationId: createRefund
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [intent_id, amount_usd]
              properties:
                intent_id: { type: string, format: uuid, description: "Payment intent to refund. Must be settled or partially funded." }
                amount_usd:
                  { type: number, description: "Refund amount in USD. Capped by the intent's refundable balance." }
                destination:
                  {
                    type: string,
                    description: "Optional payer address to refund to. If omitted, the refund waits in `pending` with `failure_reason: missing_destination` until the merchant supplies one from the dashboard.",
                  }
                reason: { type: string, maxLength: 280, example: "customer_request" }
              example:
                intent_id: "pi_11111111-1111-1111-1111-111111111111"
                amount_usd: 25.00
                destination: "0xabc0000000000000000000000000000000000000"
                reason: "customer_request"
      responses:
        "201":
          {
            description: Created,
            content: { application/json: { schema: { $ref: "#/components/schemas/Refund" } } },
          }
        "409": { description: Intent not settled }
        "422": { description: Refund exceeds refundable balance }
    get:
      tags: [Refunds]
      summary: List refunds
      operationId: listRefunds
      parameters:
        - { in: query, name: payment_intent_id, schema: { type: string, format: uuid } }
        - { in: query, name: limit, schema: { type: integer, maximum: 200, default: 50 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Refund" } }
  /api/public/v1/refunds/{id}:
    parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
    get:
      tags: [Refunds]
      summary: Retrieve a refund
      operationId: retrieveRefund
      responses:
        "200":
          {
            description: OK,
            content: { application/json: { schema: { $ref: "#/components/schemas/Refund" } } },
          }
        "404": { description: Not found }
  /api/public/v1/refunds/{id}/retry:
    parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
    post:
      tags: [Refunds]
      summary: Retry a refund (optionally to a different address)
      description: |
        Requeue a refund that is `failed`, `retry_scheduled` or `pending` for an
        immediate dispatch attempt. Supply `destination` to redirect the refund to a
        different address than the one on file — e.g. the original payer address
        bounced, cannot receive the settlement asset, or the customer supplied a new
        wallet. Refunds in a terminal state (`succeeded`, `cancelled`) return 409.
      operationId: retryRefund
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                destination:
                  {
                    type: string,
                    description: "New destination address. Omit to retry to the address already on the refund.",
                  }
                reason: { type: string, maxLength: 280, example: "address_bounced" }
              example:
                destination: "0xabc0000000000000000000000000000000000000"
                reason: "address_bounced"
      responses:
        "200":
          description: Retry dispatched
          content:
            application/json:
              schema:
                type: object
                properties:
                  retried: { type: boolean }
                  refund: { $ref: "#/components/schemas/Refund" }
        "404": { description: Not found }
        "409": { description: Refund is not in a retryable status }
        "422": { description: No destination on file and none supplied }
  /api/public/refunds/{id}/status:
    parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
    get:
      tags: [Refunds]
      summary: Payer-facing refund status (unauthenticated, safe view)
      description: |
        Public, unauthenticated status endpoint safe to expose in customer emails
        and receipts. Destination address is redacted to the last 6 characters;
        no merchant PII is returned.
      operationId: refundStatus
      security: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  payment_intent_id: { type: string, format: uuid }
                  amount_usd: { type: number }
                  chain: { type: string, nullable: true }
                  token: { type: string, nullable: true }
                  destination_tail: { type: string, description: "e.g. '…a1b2c3'" }
                  reason: { type: string }
                  status:
                    type: string
                    enum: [pending, processing, retry_scheduled, succeeded, failed, cancelled]
                  tx_hash: { type: string, nullable: true }
                  failure_reason: { type: string, nullable: true }
                  attempts: { type: integer }
                  created_at: { type: string, format: date-time }
                  updated_at: { type: string, format: date-time }
                  paid_at: { type: string, format: date-time, nullable: true }
                  terminal: { type: boolean }
        "404": { description: Not found }
  /api/public/v1/payouts:
    get:
      tags: [Payouts]
      summary: List payouts
      operationId: listPayouts
      parameters:
        - { in: query, name: status, schema: { type: string } }
        - { in: query, name: payment_intent_id, schema: { type: string, format: uuid } }
        - { in: query, name: limit, schema: { type: integer, maximum: 200, default: 50 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Payout" } }
  /api/public/v1/payouts/{id}:
    parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
    get:
      tags: [Payouts]
      summary: Retrieve a payout
      operationId: retrievePayout
      responses:
        "200":
          {
            description: OK,
            content: { application/json: { schema: { $ref: "#/components/schemas/Payout" } } },
          }
        "404": { description: Not found }
  /api/public/v1/payouts/{id}/replay:
    parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
    post:
      tags: [Payouts]
      summary: Requeue a failed payout for another dispatch attempt
      operationId: replayPayout
      responses:
        "200":
          {
            description: Requeued,
            content: { application/json: { schema: { $ref: "#/components/schemas/Payout" } } },
          }
        "409": { description: Payout is not in a replayable state }
  /api/public/v1/webhook-endpoints:
    post:
      tags: [Webhook endpoints]
      summary: Create a webhook endpoint
      operationId: createWebhookEndpoint
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url: { type: string, format: uri }
                events: { type: array, items: { type: string } }
                enabled: { type: boolean, default: true }
      responses:
        "201":
          description: Created — response includes plaintext `secret`; store immediately.
          content:
            { application/json: { schema: { $ref: "#/components/schemas/WebhookEndpoint" } } }
    get:
      tags: [Webhook endpoints]
      summary: List webhook endpoints
      operationId: listWebhookEndpoints
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/WebhookEndpoint" } }
  /api/public/v1/webhook-endpoints/{id}:
    parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
    get:
      tags: [Webhook endpoints]
      summary: Retrieve a webhook endpoint
      operationId: retrieveWebhookEndpoint
      responses:
        "200":
          {
            description: OK,
            content:
              { application/json: { schema: { $ref: "#/components/schemas/WebhookEndpoint" } } },
          }
        "404": { description: Not found }
    patch:
      tags: [Webhook endpoints]
      summary: Update a webhook endpoint
      operationId: updateWebhookEndpoint
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { type: string, format: uri }
                events: { type: array, items: { type: string } }
                enabled: { type: boolean }
      responses:
        "200":
          {
            description: OK,
            content:
              { application/json: { schema: { $ref: "#/components/schemas/WebhookEndpoint" } } },
          }
    delete:
      tags: [Webhook endpoints]
      summary: Delete a webhook endpoint
      operationId: deleteWebhookEndpoint
      responses:
        "200": { description: Deleted }
        "204": { description: Deleted }
  /api/public/v1/webhook-endpoints/{id}/rotate-secret:
    parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
    post:
      tags: [Webhook endpoints]
      summary: Rotate the signing secret. Previous secret remains valid for 24h.
      operationId: rotateWebhookEndpointSecret
      responses:
        "200":
          description: OK — new plaintext `secret` returned once.
          content:
            { application/json: { schema: { $ref: "#/components/schemas/WebhookEndpoint" } } }
  /api/public/v1/tokens:
    get:
      tags: [Tokens]
      summary: List supported tokens (unauthenticated)
      operationId: listTokens
      security: []
      parameters:
        - { in: query, name: chain, schema: { type: string } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Token" } }
